From fcb16799e0ccfc0c552d8d3e7f260f1ee838010f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:34:07 +0900 Subject: [PATCH 001/157] test: define cloud CLI help process contract --- src-tauri/tests/cli_help_health_oauth_exit.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src-tauri/tests/cli_help_health_oauth_exit.rs diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs new file mode 100644 index 000000000..bba308c06 --- /dev/null +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -0,0 +1,64 @@ +use std::process::Command; + +fn assert_help_success(binary: &str, flag: &str, usage_marker: &str) { + let output = Command::new(binary) + .env_remove("HOME") + .arg(flag) + .output() + .expect("DiskSage operational CLI must launch for its help contract"); + + assert!( + output.status.success(), + "{flag} must be a successful terminal action, got status {:?} and stderr {:?}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "successful help must not be projected through stderr" + ); + let stdout = String::from_utf8(output.stdout).expect("help output must be valid UTF-8"); + assert!( + stdout.contains(usage_marker), + "help output must contain the stable usage synopsis" + ); +} + +fn assert_help_does_not_hide_invalid_argument(binary: &str) { + let output = Command::new(binary) + .env_remove("HOME") + .args(["--help", "--opaque-option=not-shown"]) + .output() + .expect("DiskSage operational CLI must launch for invalid help composition"); + + assert!( + !output.status.success(), + "help must not turn an otherwise invalid invocation into success" + ); + assert!( + output.stdout.is_empty(), + "invalid invocation must not emit successful help on stdout" + ); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); + assert!(!stderr.is_empty(), "invalid invocation must remain visible"); + assert!( + !stderr.contains("not-shown"), + "invalid diagnostics must not echo arbitrary argument payloads" + ); +} + +#[test] +fn icloud_sync_health_help_is_successful_without_environment_dependency() { + let binary = env!("CARGO_BIN_EXE_disksage-icloud-sync-health"); + assert_help_success(binary, "--help", "usage: disksage-icloud-sync-health"); + assert_help_success(binary, "-h", "usage: disksage-icloud-sync-health"); + assert_help_does_not_hide_invalid_argument(binary); +} + +#[test] +fn provider_oauth_help_is_successful_without_environment_dependency() { + let binary = env!("CARGO_BIN_EXE_disksage-provider-oauth"); + assert_help_success(binary, "--help", "usage: disksage-provider-oauth"); + assert_help_success(binary, "-h", "usage: disksage-provider-oauth"); + assert_help_does_not_hide_invalid_argument(binary); +} From 34866eb1918b02c5f7f0bb4967f1faadef25a767 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:34:30 +0900 Subject: [PATCH 002/157] ci: run cloud CLI help process contracts --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index df6186023..46635bdfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,8 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests run: cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-cloud-plan + - name: Operational cloud CLI help contracts + run: cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --test cli_help_health_oauth_exit - name: Exact duplicate audit tests run: | cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli duplicate_audit From ad25cac6df74bb81e55b17f5a292d38b9a0c91ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:17:36 +0900 Subject: [PATCH 003/157] test: gate cloud help contract by feature --- src-tauri/tests/cli_help_health_oauth_exit.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index bba308c06..959882556 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "cloud-cli")] + use std::process::Command; fn assert_help_success(binary: &str, flag: &str, usage_marker: &str) { From 6c68832e201d592a86f0922ae78718c3329dc7b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:00:52 +0900 Subject: [PATCH 004/157] fix: make iCloud sync health help successful --- src-tauri/src/bin/disksage-icloud-sync-health.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-sync-health.rs b/src-tauri/src/bin/disksage-icloud-sync-health.rs index b35d26361..ae79ae977 100644 --- a/src-tauri/src/bin/disksage-icloud-sync-health.rs +++ b/src-tauri/src/bin/disksage-icloud-sync-health.rs @@ -85,10 +85,18 @@ fn write_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { } fn run() -> Result<(), String> { + let cli_args = std::env::args().skip(1).collect::>(); + if matches!(cli_args.as_slice(), [flag] if flag == "--help" || flag == "-h") { + println!( + "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]" + ); + return Ok(()); + } + let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| "HOME is unavailable".to_string())?; - let args = parse_args(&std::env::args().skip(1).collect::>(), &home)?; + let args = parse_args(&cli_args, &home)?; let report = probe_icloud_sync_health(&args.db_dir, now_ms()?)?; let encoded = serde_json::to_vec_pretty(&report) .map_err(|_| "icloud-sync-health-json-invalid".to_string())?; From 44b60ebaf6358336e3c7377a0d303e1bf3ac8460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:02:10 +0900 Subject: [PATCH 005/157] fix: make provider OAuth help successful --- src-tauri/src/bin/disksage-provider-oauth.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/src/bin/disksage-provider-oauth.rs index eb16b49dd..90b47f6af 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ b/src-tauri/src/bin/disksage-provider-oauth.rs @@ -391,6 +391,11 @@ fn execute(args: Args) -> Result { #[cfg(not(coverage))] fn run() -> Result<(), String> { let args = std::env::args().skip(1).collect::>(); + if matches!(args.as_slice(), [flag] if flag == "--help" || flag == "-h") { + println!("{}", usage()); + return Ok(()); + } + let parsed = parse_args(&args, std::env::var_os("HOME").map(PathBuf::from))?; let output = execute(parsed)?; println!( From ab2c29bb57dd45b58cb5de82be121967c1214e61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:17:11 +0900 Subject: [PATCH 006/157] test: require exact and bounded cloud CLI help --- src-tauri/tests/cli_help_health_oauth_exit.rs | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index 959882556..8e267dda6 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -2,7 +2,7 @@ use std::process::Command; -fn assert_help_success(binary: &str, flag: &str, usage_marker: &str) { +fn assert_help_success(binary: &str, flag: &str, expected_usage: &str) { let output = Command::new(binary) .env_remove("HOME") .arg(flag) @@ -20,26 +20,27 @@ fn assert_help_success(binary: &str, flag: &str, usage_marker: &str) { "successful help must not be projected through stderr" ); let stdout = String::from_utf8(output.stdout).expect("help output must be valid UTF-8"); - assert!( - stdout.contains(usage_marker), - "help output must contain the stable usage synopsis" + assert_eq!( + stdout, + format!("{expected_usage}\n"), + "help output must equal the stable usage synopsis" ); } -fn assert_help_does_not_hide_invalid_argument(binary: &str) { +fn assert_invalid_argument_is_bounded(binary: &str, arguments: &[&str]) { let output = Command::new(binary) .env_remove("HOME") - .args(["--help", "--opaque-option=not-shown"]) + .args(arguments) .output() - .expect("DiskSage operational CLI must launch for invalid help composition"); + .expect("DiskSage operational CLI must launch for invalid argument validation"); assert!( !output.status.success(), - "help must not turn an otherwise invalid invocation into success" + "an invalid invocation must remain a non-zero failure" ); assert!( output.stdout.is_empty(), - "invalid invocation must not emit successful help on stdout" + "invalid invocation must not emit successful output on stdout" ); let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must be valid UTF-8"); assert!(!stderr.is_empty(), "invalid invocation must remain visible"); @@ -52,15 +53,19 @@ fn assert_help_does_not_hide_invalid_argument(binary: &str) { #[test] fn icloud_sync_health_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-icloud-sync-health"); - assert_help_success(binary, "--help", "usage: disksage-icloud-sync-health"); - assert_help_success(binary, "-h", "usage: disksage-icloud-sync-health"); - assert_help_does_not_hide_invalid_argument(binary); + let expected_usage = "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]"; + assert_help_success(binary, "--help", expected_usage); + assert_help_success(binary, "-h", expected_usage); + assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); + assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); } #[test] fn provider_oauth_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-provider-oauth"); - assert_help_success(binary, "--help", "usage: disksage-provider-oauth"); - assert_help_success(binary, "-h", "usage: disksage-provider-oauth"); - assert_help_does_not_hide_invalid_argument(binary); + let expected_usage = "usage: disksage-provider-oauth [--home ABSOLUTE_PATH] [--connections ABSOLUTE_PATH] (--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID [--manual-browser] | --verify-capacity --cloud-root ABSOLUTE_PATH | --disconnect --cloud-root ABSOLUTE_PATH)"; + assert_help_success(binary, "--help", expected_usage); + assert_help_success(binary, "-h", expected_usage); + assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); + assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); } From 2dc16bb057bb204dd7ad85ba443befbd853f9579 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:35:42 +0900 Subject: [PATCH 007/157] test: reject non-UTF8 cloud CLI arguments --- src-tauri/tests/cli_help_health_oauth_exit.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index 8e267dda6..0ded90476 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -50,6 +50,32 @@ fn assert_invalid_argument_is_bounded(binary: &str, arguments: &[&str]) { ); } +#[cfg(unix)] +fn assert_non_utf8_argument_is_bounded(binary: &str) { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let opaque = OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); + let output = Command::new(binary) + .env_remove("HOME") + .arg(opaque) + .output() + .expect("DiskSage operational CLI must launch for non-UTF-8 argument validation"); + + assert_eq!( + output.status.code(), + Some(2), + "invalid non-UTF-8 input must use the ordinary bounded argument-error exit" + ); + assert!(output.stdout.is_empty(), "invalid non-UTF-8 input must not emit successful output"); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); + assert!(!stderr.is_empty(), "invalid non-UTF-8 input must remain visible"); + assert!( + !stderr.contains("panicked") && !stderr.contains("thread 'main'"), + "invalid host arguments must not escape through a Rust panic" + ); +} + #[test] fn icloud_sync_health_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-icloud-sync-health"); @@ -58,6 +84,8 @@ fn icloud_sync_health_help_is_successful_without_environment_dependency() { assert_help_success(binary, "-h", expected_usage); assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary); } #[test] @@ -68,4 +96,6 @@ fn provider_oauth_help_is_successful_without_environment_dependency() { assert_help_success(binary, "-h", expected_usage); assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary); } From 418d29835cb5a9ad4f4b23aa8b711f7440d4ebdf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 03:12:53 +0900 Subject: [PATCH 008/157] fix: bound iCloud sync health unknown arguments --- src-tauri/src/bin/disksage-icloud-sync-health.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-sync-health.rs b/src-tauri/src/bin/disksage-icloud-sync-health.rs index ae79ae977..588640908 100644 --- a/src-tauri/src/bin/disksage-icloud-sync-health.rs +++ b/src-tauri/src/bin/disksage-icloud-sync-health.rs @@ -45,7 +45,7 @@ fn parse_args(args: &[String], home: &Path) -> Result { "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]".into(), ); } - flag => return Err(format!("unknown argument: {flag}")), + _unknown => return Err("icloud-sync-health-unknown-argument".into()), } index += 1; } From 68e2122c12366743a0bb00e6570d72ea3964fc99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:20:42 +0900 Subject: [PATCH 009/157] fix: fail closed on non-UTF8 sync-health arguments --- .../src/bin/disksage-icloud-sync-health.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bin/disksage-icloud-sync-health.rs b/src-tauri/src/bin/disksage-icloud-sync-health.rs index 588640908..ef7d71278 100644 --- a/src-tauri/src/bin/disksage-icloud-sync-health.rs +++ b/src-tauri/src/bin/disksage-icloud-sync-health.rs @@ -84,8 +84,19 @@ fn write_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { .map_err(|_| "icloud-sync-health-output-write-failed".to_string()) } +fn command_line_args() -> Result, String> { + std::env::args_os() + .skip(1) + .map(|argument| { + argument + .into_string() + .map_err(|_| "icloud-sync-health-invalid-utf8-argument".to_string()) + }) + .collect() +} + fn run() -> Result<(), String> { - let cli_args = std::env::args().skip(1).collect::>(); + let cli_args = command_line_args()?; if matches!(cli_args.as_slice(), [flag] if flag == "--help" || flag == "-h") { println!( "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]" @@ -112,8 +123,13 @@ fn run() -> Result<(), String> { fn main() { if let Err(error) = run() { + let exit_code = if error == "icloud-sync-health-invalid-utf8-argument" { + 2 + } else { + 1 + }; eprintln!("DiskSage iCloud sync health: {error}"); - std::process::exit(1); + std::process::exit(exit_code); } } From 4439d60e914a77ea8ca8cb08a5335714eb52b06a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 04:23:27 +0900 Subject: [PATCH 010/157] fix: fail closed on non-UTF8 provider OAuth arguments --- src-tauri/src/bin/disksage-provider-oauth.rs | 21 ++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/src/bin/disksage-provider-oauth.rs index 90b47f6af..63d23c0af 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ b/src-tauri/src/bin/disksage-provider-oauth.rs @@ -388,9 +388,21 @@ fn execute(args: Args) -> Result { } } +#[cfg(not(coverage))] +fn command_line_args() -> Result, String> { + std::env::args_os() + .skip(1) + .map(|argument| { + argument + .into_string() + .map_err(|_| "provider-oauth-invalid-utf8-argument".to_string()) + }) + .collect() +} + #[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = std::env::args().skip(1).collect::>(); + let args = command_line_args()?; if matches!(args.as_slice(), [flag] if flag == "--help" || flag == "-h") { println!("{}", usage()); return Ok(()); @@ -409,8 +421,13 @@ fn run() -> Result<(), String> { #[cfg(not(coverage))] fn main() { if let Err(error) = run() { + let exit_code = if error == "provider-oauth-invalid-utf8-argument" { + 2 + } else { + 1 + }; eprintln!("{error}"); - std::process::exit(1); + std::process::exit(exit_code); } } From 1a1ba650aa1338580752c79d3a035d3e3c4cf865 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 16:20:03 +0900 Subject: [PATCH 011/157] test: reject non-UTF-8 argument reflection --- src-tauri/tests/cli_help_health_oauth_exit.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index 0ded90476..e8779a869 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -55,7 +55,8 @@ fn assert_non_utf8_argument_is_bounded(binary: &str) { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; - let opaque = OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); + let opaque = + OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); let output = Command::new(binary) .env_remove("HOME") .arg(opaque) @@ -67,9 +68,17 @@ fn assert_non_utf8_argument_is_bounded(binary: &str) { Some(2), "invalid non-UTF-8 input must use the ordinary bounded argument-error exit" ); - assert!(output.stdout.is_empty(), "invalid non-UTF-8 input must not emit successful output"); - let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); + assert!( + output.stdout.is_empty(), + "invalid non-UTF-8 input must not emit successful output" + ); + let stderr = + String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); assert!(!stderr.is_empty(), "invalid non-UTF-8 input must remain visible"); + assert!( + !stderr.contains("opaque"), + "invalid diagnostics must not echo opaque argument payloads" + ); assert!( !stderr.contains("panicked") && !stderr.contains("thread 'main'"), "invalid host arguments must not escape through a Rust panic" From dd12e0407a1808955da127a5071f423354eef0e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:11:04 -0700 Subject: [PATCH 012/157] refactor: extract provider oauth platform entry --- .../src/bin/disksage-provider-oauth-entry.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src-tauri/src/bin/disksage-provider-oauth-entry.rs diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs new file mode 100644 index 000000000..cb52259c2 --- /dev/null +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -0,0 +1,89 @@ +//! Platform-aware entrypoint for the provider OAuth operational CLI. +//! +//! The domain implementation stays in `disksage-provider-oauth.rs`. This entry owns only host +//! argument decoding, terminal help, and platform home-directory selection before delegating to +//! the existing parser and OAuth execution boundary. + +use std::path::PathBuf; + +mod implementation { + include!("disksage-provider-oauth.rs"); + + #[cfg(not(coverage))] + pub(super) fn usage_text() -> String { + usage() + } + + #[cfg(not(coverage))] + pub(super) fn run_with_environment_home( + args: &[String], + environment_home: Option, + ) -> Result<(), String> { + let parsed = parse_args(args, environment_home)?; + let output = execute(parsed)?; + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|_| "provider-oauth-output-serialization-failed".to_string())? + ); + Ok(()) + } +} + +/// Resolve the platform home authority supplied to the existing OAuth parser. +/// +/// This initial extraction intentionally preserves the predecessor behavior: only `HOME` is used. +/// The function is pure so the Windows authority contract can be exercised on every CI host before +/// changing shipped behavior. +pub(crate) fn environment_home_from( + home: Option, + user_profile: Option, + windows: bool, +) -> Option { + let _ = (user_profile, windows); + home +} + +#[cfg(not(coverage))] +fn command_line_args() -> Result, String> { + std::env::args_os() + .skip(1) + .map(|argument| { + argument + .into_string() + .map_err(|_| "provider-oauth-invalid-utf8-argument".to_string()) + }) + .collect() +} + +#[cfg(not(coverage))] +fn run() -> Result<(), String> { + let args = command_line_args()?; + if matches!(args.as_slice(), [flag] if flag == "--help" || flag == "-h") { + println!("{}", implementation::usage_text()); + return Ok(()); + } + + let environment_home = environment_home_from( + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("USERPROFILE").map(PathBuf::from), + cfg!(windows), + ); + implementation::run_with_environment_home(&args, environment_home) +} + +#[cfg(not(coverage))] +fn main() { + if let Err(error) = run() { + let exit_code = if error == "provider-oauth-invalid-utf8-argument" { + 2 + } else { + 1 + }; + eprintln!("{error}"); + std::process::exit(exit_code); + } +} + +#[cfg(coverage)] +fn main() {} From 550193e213b8706d134af805ffc29efefe13fcc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:11:27 -0700 Subject: [PATCH 013/157] test: require Windows OAuth USERPROFILE authority --- ...rovider_oauth_environment_home_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_environment_home_contract.rs diff --git a/src-tauri/tests/provider_oauth_environment_home_contract.rs b/src-tauri/tests/provider_oauth_environment_home_contract.rs new file mode 100644 index 000000000..e90356492 --- /dev/null +++ b/src-tauri/tests/provider_oauth_environment_home_contract.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; + +#[path = "../src/bin/disksage-provider-oauth-entry.rs"] +mod provider_oauth_entry; + +#[test] +fn windows_home_authority_matches_core_userprofile_contract() { + let unix_style_home = PathBuf::from("C:/unexpected-home"); + let user_profile = PathBuf::from("C:/Users/DiskSageOperator"); + + assert_eq!( + provider_oauth_entry::environment_home_from( + Some(unix_style_home.clone()), + Some(user_profile.clone()), + true, + ), + Some(user_profile), + "Windows provider OAuth must use the same USERPROFILE home authority as DiskSage core", + ); + assert_eq!( + provider_oauth_entry::environment_home_from(Some(unix_style_home), None, true), + None, + "Windows must fail closed when its canonical USERPROFILE authority is unavailable", + ); +} + +#[test] +fn non_windows_home_authority_remains_home() { + let home = PathBuf::from("/home/disksage"); + let unrelated_user_profile = PathBuf::from("C:/Users/Foreign"); + + assert_eq!( + provider_oauth_entry::environment_home_from( + Some(home.clone()), + Some(unrelated_user_profile), + false, + ), + Some(home), + ); + assert_eq!( + provider_oauth_entry::environment_home_from(None, None, false), + None, + ); +} From c3868f04e1148e84e307c117c235c8a66114fa7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:13:41 -0700 Subject: [PATCH 014/157] fix: use Windows USERPROFILE for provider OAuth --- src-tauri/Cargo.toml | 2 +- src-tauri/src/bin/disksage-provider-oauth-entry.rs | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 228e0e711..9984e5ea1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -126,7 +126,7 @@ path = "src/bin/disksage-podman-reclaim-plan.rs" [[bin]] name = "disksage-provider-oauth" -path = "src/bin/disksage-provider-oauth.rs" +path = "src/bin/disksage-provider-oauth-entry.rs" required-features = ["cloud-cli"] [[bin]] diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index cb52259c2..6b720747e 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -32,16 +32,19 @@ mod implementation { /// Resolve the platform home authority supplied to the existing OAuth parser. /// -/// This initial extraction intentionally preserves the predecessor behavior: only `HOME` is used. -/// The function is pure so the Windows authority contract can be exercised on every CI host before -/// changing shipped behavior. +/// Windows follows the same authority as DiskSage core (`USERPROFILE`). Other platforms keep +/// `HOME`. Missing canonical authority fails closed instead of borrowing another platform's +/// environment convention. pub(crate) fn environment_home_from( home: Option, user_profile: Option, windows: bool, ) -> Option { - let _ = (user_profile, windows); - home + if windows { + user_profile + } else { + home + } } #[cfg(not(coverage))] From 2ff8b897d020e91baaa34fae8138ca939903bc67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 03:15:50 -0700 Subject: [PATCH 015/157] fix: isolate provider OAuth implementation from bin discovery --- ...ovider-oauth.rs => provider_oauth_cli_impl.rs.inc} | 0 src-tauri/src/bin/disksage-provider-oauth-entry.rs | 11 +++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) rename src-tauri/{src/bin/disksage-provider-oauth.rs => provider_oauth_cli_impl.rs.inc} (100%) diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/provider_oauth_cli_impl.rs.inc similarity index 100% rename from src-tauri/src/bin/disksage-provider-oauth.rs rename to src-tauri/provider_oauth_cli_impl.rs.inc diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 6b720747e..1486487ce 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -1,13 +1,16 @@ //! Platform-aware entrypoint for the provider OAuth operational CLI. //! -//! The domain implementation stays in `disksage-provider-oauth.rs`. This entry owns only host -//! argument decoding, terminal help, and platform home-directory selection before delegating to -//! the existing parser and OAuth execution boundary. +//! The domain implementation stays outside `src/bin` so Cargo/Tauri binary discovery sees only +//! this real entrypoint. This entry owns host argument decoding, terminal help, and platform +//! home-directory selection before delegating to the existing OAuth execution boundary. use std::path::PathBuf; mod implementation { - include!("disksage-provider-oauth.rs"); + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/provider_oauth_cli_impl.rs.inc" + )); #[cfg(not(coverage))] pub(super) fn usage_text() -> String { From 779afa48cc8bc534a6e5cc910714324d85f7358b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 14:20:38 +0900 Subject: [PATCH 016/157] fix: keep provider oauth help contract executable --- src-tauri/provider_oauth_cli_impl.rs.inc | 56 ++----------------- src-tauri/tests/cli_help_health_oauth_exit.rs | 2 +- 2 files changed, 6 insertions(+), 52 deletions(-) diff --git a/src-tauri/provider_oauth_cli_impl.rs.inc b/src-tauri/provider_oauth_cli_impl.rs.inc index 63d23c0af..9ef646db7 100644 --- a/src-tauri/provider_oauth_cli_impl.rs.inc +++ b/src-tauri/provider_oauth_cli_impl.rs.inc @@ -1,8 +1,8 @@ -//! Headless OAuth lifecycle for OneDrive and Google Drive evidence and explicit API uploads. -//! -//! Refresh tokens remain in the operating-system credential store. This command emits only -//! non-secret connection descriptors and provider capacity evidence; this command itself never -//! performs a cloud file write or source eviction. +// Headless OAuth lifecycle for OneDrive and Google Drive evidence and explicit API uploads. +// +// Refresh tokens remain in the operating-system credential store. This command emits only +// non-secret connection descriptors and provider capacity evidence; this command itself never +// performs a cloud file write or source eviction. #[cfg(not(coverage))] use std::path::{Path, PathBuf}; @@ -388,52 +388,6 @@ fn execute(args: Args) -> Result { } } -#[cfg(not(coverage))] -fn command_line_args() -> Result, String> { - std::env::args_os() - .skip(1) - .map(|argument| { - argument - .into_string() - .map_err(|_| "provider-oauth-invalid-utf8-argument".to_string()) - }) - .collect() -} - -#[cfg(not(coverage))] -fn run() -> Result<(), String> { - let args = command_line_args()?; - if matches!(args.as_slice(), [flag] if flag == "--help" || flag == "-h") { - println!("{}", usage()); - return Ok(()); - } - - let parsed = parse_args(&args, std::env::var_os("HOME").map(PathBuf::from))?; - let output = execute(parsed)?; - println!( - "{}", - serde_json::to_string_pretty(&output) - .map_err(|_| "provider-oauth-output-serialization-failed".to_string())? - ); - Ok(()) -} - -#[cfg(not(coverage))] -fn main() { - if let Err(error) = run() { - let exit_code = if error == "provider-oauth-invalid-utf8-argument" { - 2 - } else { - 1 - }; - eprintln!("{error}"); - std::process::exit(exit_code); - } -} - -#[cfg(coverage)] -fn main() {} - #[cfg(all(test, not(coverage)))] mod tests { use super::*; diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index e8779a869..5c92c07e9 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -100,7 +100,7 @@ fn icloud_sync_health_help_is_successful_without_environment_dependency() { #[test] fn provider_oauth_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-provider-oauth"); - let expected_usage = "usage: disksage-provider-oauth [--home ABSOLUTE_PATH] [--connections ABSOLUTE_PATH] (--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID [--manual-browser] | --verify-capacity --cloud-root ABSOLUTE_PATH | --disconnect --cloud-root ABSOLUTE_PATH)"; + let expected_usage = "usage: disksage-provider-oauth [--home ABSOLUTE_PATH] [--connections ABSOLUTE_PATH] (--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID [--manual-browser] [--write-access] | --verify-capacity --cloud-root ABSOLUTE_PATH | --disconnect --cloud-root ABSOLUTE_PATH)"; assert_help_success(binary, "--help", expected_usage); assert_help_success(binary, "-h", expected_usage); assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); From 81c44e43205f21276c39f055f1878805f36e1072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:11:21 +0900 Subject: [PATCH 017/157] test: exercise mixed cli errors after argument parsing --- src-tauri/tests/cli_help_health_oauth_exit.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index 5c92c07e9..4b2e2ed03 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -29,7 +29,9 @@ fn assert_help_success(binary: &str, flag: &str, expected_usage: &str) { fn assert_invalid_argument_is_bounded(binary: &str, arguments: &[&str]) { let output = Command::new(binary) - .env_remove("HOME") + // Keep HOME available so mixed help+invalid invocations exercise argument validation, + // rather than the unrelated environment-authority failure path. + .env("HOME", "/private/tmp/disksage-cli-test-home") .args(arguments) .output() .expect("DiskSage operational CLI must launch for invalid argument validation"); From 4d0cc91b7374a9a74854bdc707e4bfc62de1e305 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 00:39:25 +0900 Subject: [PATCH 018/157] docs(cli): guide cloud health and connection actions --- src-tauri/provider_oauth_cli_impl.rs.inc | 4 +++- src-tauri/src/bin/disksage-icloud-sync-health.rs | 11 +++++------ src-tauri/tests/cli_help_health_oauth_exit.rs | 15 ++++++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src-tauri/provider_oauth_cli_impl.rs.inc b/src-tauri/provider_oauth_cli_impl.rs.inc index 9ef646db7..e261170ed 100644 --- a/src-tauri/provider_oauth_cli_impl.rs.inc +++ b/src-tauri/provider_oauth_cli_impl.rs.inc @@ -94,7 +94,9 @@ fn usage() -> String { "[--connections ABSOLUTE_PATH] ", "(--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID ", "[--manual-browser] [--write-access] | --verify-capacity --cloud-root ABSOLUTE_PATH | ", - "--disconnect --cloud-root ABSOLUTE_PATH)" + "--disconnect --cloud-root ABSOLUTE_PATH)\n", + "다음 단계: 먼저 --list로 연결을 확인하세요. --connect는 동의 후 자격 증명을 로컬 보안 저장소에 보관하며, ", + "--write-access는 명시적으로 선택할 때만 요청됩니다. 출력에는 토큰이 포함되지 않습니다." ) .into() } diff --git a/src-tauri/src/bin/disksage-icloud-sync-health.rs b/src-tauri/src/bin/disksage-icloud-sync-health.rs index ef7d71278..d2e877168 100644 --- a/src-tauri/src/bin/disksage-icloud-sync-health.rs +++ b/src-tauri/src/bin/disksage-icloud-sync-health.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +const USAGE: &str = "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]\n\ +다음 단계: 차단 상태와 근거 시각을 확인하세요. 이 명령은 동기화 데이터베이스를 변경하지 않습니다."; + #[derive(Debug, Clone, PartialEq, Eq)] struct Args { db_dir: PathBuf, @@ -41,9 +44,7 @@ fn parse_args(args: &[String], home: &Path) -> Result { } } "--help" | "-h" => { - return Err( - "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]".into(), - ); + return Err(USAGE.into()); } _unknown => return Err("icloud-sync-health-unknown-argument".into()), } @@ -98,9 +99,7 @@ fn command_line_args() -> Result, String> { fn run() -> Result<(), String> { let cli_args = command_line_args()?; if matches!(cli_args.as_slice(), [flag] if flag == "--help" || flag == "-h") { - println!( - "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]" - ); + println!("{USAGE}"); return Ok(()); } diff --git a/src-tauri/tests/cli_help_health_oauth_exit.rs b/src-tauri/tests/cli_help_health_oauth_exit.rs index 4b2e2ed03..1498f14b6 100644 --- a/src-tauri/tests/cli_help_health_oauth_exit.rs +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -57,8 +57,7 @@ fn assert_non_utf8_argument_is_bounded(binary: &str) { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; - let opaque = - OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); + let opaque = OsString::from_vec(vec![b'-', b'-', b'o', b'p', b'a', b'q', b'u', b'e', 0xff]); let output = Command::new(binary) .env_remove("HOME") .arg(opaque) @@ -74,9 +73,11 @@ fn assert_non_utf8_argument_is_bounded(binary: &str) { output.stdout.is_empty(), "invalid non-UTF-8 input must not emit successful output" ); - let stderr = - String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); - assert!(!stderr.is_empty(), "invalid non-UTF-8 input must remain visible"); + let stderr = String::from_utf8(output.stderr).expect("CLI diagnostics must remain valid UTF-8"); + assert!( + !stderr.is_empty(), + "invalid non-UTF-8 input must remain visible" + ); assert!( !stderr.contains("opaque"), "invalid diagnostics must not echo opaque argument payloads" @@ -90,7 +91,7 @@ fn assert_non_utf8_argument_is_bounded(binary: &str) { #[test] fn icloud_sync_health_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-icloud-sync-health"); - let expected_usage = "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]"; + let expected_usage = "usage: disksage-icloud-sync-health [--db-dir ABSOLUTE_CLOUDDOCS_DB_DIR] [--output ABSOLUTE_NEW_FILE.json]\n다음 단계: 차단 상태와 근거 시각을 확인하세요. 이 명령은 동기화 데이터베이스를 변경하지 않습니다."; assert_help_success(binary, "--help", expected_usage); assert_help_success(binary, "-h", expected_usage); assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); @@ -102,7 +103,7 @@ fn icloud_sync_health_help_is_successful_without_environment_dependency() { #[test] fn provider_oauth_help_is_successful_without_environment_dependency() { let binary = env!("CARGO_BIN_EXE_disksage-provider-oauth"); - let expected_usage = "usage: disksage-provider-oauth [--home ABSOLUTE_PATH] [--connections ABSOLUTE_PATH] (--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID [--manual-browser] [--write-access] | --verify-capacity --cloud-root ABSOLUTE_PATH | --disconnect --cloud-root ABSOLUTE_PATH)"; + let expected_usage = "usage: disksage-provider-oauth [--home ABSOLUTE_PATH] [--connections ABSOLUTE_PATH] (--list | --connect --cloud-root ABSOLUTE_PATH --client-id ID [--manual-browser] [--write-access] | --verify-capacity --cloud-root ABSOLUTE_PATH | --disconnect --cloud-root ABSOLUTE_PATH)\n다음 단계: 먼저 --list로 연결을 확인하세요. --connect는 동의 후 자격 증명을 로컬 보안 저장소에 보관하며, --write-access는 명시적으로 선택할 때만 요청됩니다. 출력에는 토큰이 포함되지 않습니다."; assert_help_success(binary, "--help", expected_usage); assert_help_success(binary, "-h", expected_usage); assert_invalid_argument_is_bounded(binary, &["--opaque-option=not-shown"]); From 5e2e54be1a7f8e1f47680ac1ff1a607dc6b99f9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:40:29 +0900 Subject: [PATCH 019/157] test: reject invalid negative paths-ignore filters --- src/lib/testWorkflowPathFilterContract.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/lib/testWorkflowPathFilterContract.test.ts diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts new file mode 100644 index 000000000..b47adf89f --- /dev/null +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); + +describe("test workflow path-filter contract", () => { + it("does not put negative globs under paths-ignore", () => { + const ignoreBlocks = workflow.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + + for (const match of ignoreBlocks) { + expect(match[1]).not.toMatch(/^\s*-\s+["']?!/m); + } + }); +}); From 47d16e078f1171f64952e0a1527c69203cbd5b76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:40:56 +0900 Subject: [PATCH 020/157] fix(ci): use valid ordered path filters for contract docs --- .github/workflows/test.yml | 46 ++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b35c94808..49f62e82c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,29 +3,31 @@ name: Test on: push: branches: [main] - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" pull_request: - paths-ignore: - - "docs/**" - - "*.md" - # Content-checked by contract tests (vitest + cargo test) — must still run CI. - - "!docs/doctoring/release-artifact-provenance.md" - - "!docs/doctoring/tauri-content-security-policy.md" - - "!docs/doctoring/model-artifact-integrity.md" - - "!docs/doctoring/model-load-handle-binding.md" - - "!docs/development/icloud-local-eviction-batch.md" - - "!docs/architecture/goals/cloud-offload-goal.json" - - "!CHANGELOG.md" + paths: + - "**" + - "!docs/**" + - "!*.md" + # GitHub supports re-inclusion only with ordered positive patterns under `paths`. + - "docs/doctoring/release-artifact-provenance.md" + - "docs/doctoring/tauri-content-security-policy.md" + - "docs/doctoring/model-artifact-integrity.md" + - "docs/doctoring/model-load-handle-binding.md" + - "docs/development/icloud-local-eviction-batch.md" + - "docs/architecture/goals/cloud-offload-goal.json" + - "CHANGELOG.md" permissions: contents: read From 439431af45d3b9c10fd78d798767e9dbfca6fba0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:51:58 +0900 Subject: [PATCH 021/157] test: reproduce paths-ignore parser blind spots --- .../testWorkflowPathFilterContract.test.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index b47adf89f..3454f32cb 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -6,12 +6,31 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); +function negativePathsIgnoreEntries(source: string): string[] { + const entries: string[] = []; + const ignoreBlocks = source.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + for (const match of ignoreBlocks) { + for (const line of match[1].split("\n")) { + const item = line.match(/^\s*-\s+["']?(![^"'\s]+)["']?\s*$/); + if (item) entries.push(item[1]); + } + } + return entries; +} + describe("test workflow path-filter contract", () => { - it("does not put negative globs under paths-ignore", () => { - const ignoreBlocks = workflow.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); + it("detects negative paths-ignore entries after comments and in inline lists", () => { + const fixtures = [ + `pull_request:\n paths-ignore:\n - "docs/**"\n # contract exception\n - "!docs/example.md"\n`, + `push:\n paths-ignore: ["docs/**", "!docs/example.md"]\n`, + ]; - for (const match of ignoreBlocks) { - expect(match[1]).not.toMatch(/^\s*-\s+["']?!/m); + for (const fixture of fixtures) { + expect(negativePathsIgnoreEntries(fixture)).toContain("!docs/example.md"); } }); + + it("does not put negative globs under paths-ignore", () => { + expect(negativePathsIgnoreEntries(workflow)).toEqual([]); + }); }); From 80c8971f5eca6dcdf5590491a708162a2d3a3d7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 20:52:23 +0900 Subject: [PATCH 022/157] fix(test): inspect every paths-ignore list item --- .../testWorkflowPathFilterContract.test.ts | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 3454f32cb..c86981d0c 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -6,16 +6,56 @@ import { describe, expect, it } from "vitest"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const workflow = readFileSync(resolve(repositoryRoot, ".github/workflows/test.yml"), "utf8"); +function scalarValue(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"')) { + const end = value.indexOf('"', 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + return end >= 0 ? value.slice(1, end) : value.slice(1); + } + return value.split(/\s+#/, 1)[0].trim(); +} + function negativePathsIgnoreEntries(source: string): string[] { - const entries: string[] = []; - const ignoreBlocks = source.matchAll(/paths-ignore:\n((?:\s+-\s+[^\n]+\n?)+)/g); - for (const match of ignoreBlocks) { - for (const line of match[1].split("\n")) { - const item = line.match(/^\s*-\s+["']?(![^"'\s]+)["']?\s*$/); - if (item) entries.push(item[1]); + const negatives: string[] = []; + const lines = source.split(/\r?\n/); + + for (let index = 0; index < lines.length; index += 1) { + const key = lines[index].match(/^(\s*)paths-ignore:\s*(.*)$/); + if (!key) continue; + + const keyIndent = key[1].length; + const inline = key[2].trim(); + if (inline) { + const listBody = inline.startsWith("[") && inline.endsWith("]") + ? inline.slice(1, -1) + : inline; + for (const rawItem of listBody.split(",")) { + const value = scalarValue(rawItem); + if (value.startsWith("!")) negatives.push(value); + } + continue; + } + + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const indent = line.length - line.trimStart().length; + if (indent <= keyIndent) break; + + const listItem = trimmed.match(/^-\s*(.+)$/); + if (!listItem) continue; + const value = scalarValue(listItem[1]); + if (value.startsWith("!")) negatives.push(value); } } - return entries; + + return negatives; } describe("test workflow path-filter contract", () => { From ab6812449e60583baf2402fb06e1f5fdc8629140 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 01:24:19 +0900 Subject: [PATCH 023/157] test: require real Windows provider OAuth process evidence --- ...oviderOauthWindowsWorkflowContract.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/lib/providerOauthWindowsWorkflowContract.test.ts diff --git a/src/lib/providerOauthWindowsWorkflowContract.test.ts b/src/lib/providerOauthWindowsWorkflowContract.test.ts new file mode 100644 index 000000000..b13fb0a50 --- /dev/null +++ b/src/lib/providerOauthWindowsWorkflowContract.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +function readWorkflow(): string { + return readFileSync( + resolve(repositoryRoot, '.github/workflows/provider-oauth-windows.yml'), + 'utf8', + ).replace(/\r\n?/g, '\n'); +} + +describe('Windows provider OAuth executable evidence', () => { + it('runs the real USERPROFILE process regression on a bounded Windows runner', () => { + const workflow = readWorkflow(); + + expect(workflow).toContain('name: Provider OAuth Windows Contract'); + expect(workflow).toContain('runs-on: windows-2022'); + expect(workflow).toContain('timeout-minutes: 30'); + expect(workflow).toContain('permissions:\n contents: read'); + expect(workflow).toContain('persist-credentials: false'); + expect(workflow).toContain('ref: ${{ github.event.pull_request.head.sha || github.sha }}'); + expect(workflow).toContain('workspaces: src-tauri'); + expect(workflow).toContain('cache-targets: false'); + expect(workflow).toContain( + 'cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process', + ); + expect(workflow).not.toContain('secrets.'); + expect(workflow).not.toContain('contents: write'); + }); +}); From 20a5fb375dc28eb564f413d05a4aeb16e37ab394 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 01:24:43 +0900 Subject: [PATCH 024/157] test: exercise provider OAuth USERPROFILE boundary --- src-tauri/tests/provider_oauth_cli_process.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_cli_process.rs diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs new file mode 100644 index 000000000..ae035233e --- /dev/null +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -0,0 +1,34 @@ +#![cfg(feature = "cloud-cli")] + +//! Black-box Windows regression for the shipped provider OAuth CLI home-authority boundary. +//! +//! The platform-neutral `environment_home_from` contract is not enough to prove that the packaged +//! process actually observes Windows `USERPROFILE` when `HOME` is absent. This test launches the +//! real feature-gated binary and stays on the read-only `--list` path, so it performs no browser, +//! network, credential-store, provider-write, or source-eviction work. + +#[cfg(windows)] +#[test] +fn read_only_list_uses_userprofile_when_home_is_absent() { + use std::process::Command; + + let temp = tempfile::tempdir().expect("temporary Windows profile root should be created"); + let output = Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) + .env_remove("HOME") + .env("USERPROFILE", temp.path()) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 0); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["secrets_included"], false); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} From b85e16042c2f8ec5bee6c0dbfde6049f087c549e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 01:24:58 +0900 Subject: [PATCH 025/157] ci: run provider OAuth process contract on Windows --- .github/workflows/provider-oauth-windows.yml | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/provider-oauth-windows.yml diff --git a/.github/workflows/provider-oauth-windows.yml b/.github/workflows/provider-oauth-windows.yml new file mode 100644 index 000000000..953a5bddc --- /dev/null +++ b/.github/workflows/provider-oauth-windows.yml @@ -0,0 +1,33 @@ +name: Provider OAuth Windows Contract + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: provider-oauth-windows-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.run_attempt == 1 }} + +permissions: + contents: read + +jobs: + provider-oauth-windows: + runs-on: windows-2022 + timeout-minutes: 30 + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUST_TEST_THREADS: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + cache-targets: false + - name: Run shipped provider OAuth Windows process contract + run: cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process From c3f7b62d897611e26f6d70f8390f8dc8fef33349 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 01:25:24 +0900 Subject: [PATCH 026/157] merge(ci): retain current docs path filters --- .github/workflows/test.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46635bdfd..d657baa1a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,29 @@ name: Test on: push: branches: [main] + paths-ignore: + - "docs/**" + - "*.md" + # Content-checked by contract tests (vitest + cargo test) — must still run CI. + - "!docs/doctoring/release-artifact-provenance.md" + - "!docs/doctoring/tauri-content-security-policy.md" + - "!docs/doctoring/model-artifact-integrity.md" + - "!docs/doctoring/model-load-handle-binding.md" + - "!docs/development/icloud-local-eviction-batch.md" + - "!docs/architecture/goals/cloud-offload-goal.json" + - "!CHANGELOG.md" pull_request: + paths-ignore: + - "docs/**" + - "*.md" + # Content-checked by contract tests (vitest + cargo test) — must still run CI. + - "!docs/doctoring/release-artifact-provenance.md" + - "!docs/doctoring/tauri-content-security-policy.md" + - "!docs/doctoring/model-artifact-integrity.md" + - "!docs/doctoring/model-load-handle-binding.md" + - "!docs/development/icloud-local-eviction-batch.md" + - "!docs/architecture/goals/cloud-offload-goal.json" + - "!CHANGELOG.md" permissions: contents: read From 45dc68cf80b5206590ec0dd88602a58856ae1a37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:04:30 +0900 Subject: [PATCH 027/157] test(security): reject unknown OAuth authority fields --- .../provider_oauth_unknown_field_rejection.rs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_unknown_field_rejection.rs diff --git a/src-tauri/tests/provider_oauth_unknown_field_rejection.rs b/src-tauri/tests/provider_oauth_unknown_field_rejection.rs new file mode 100644 index 000000000..2f752c265 --- /dev/null +++ b/src-tauri/tests/provider_oauth_unknown_field_rejection.rs @@ -0,0 +1,109 @@ +//! Fail-closed schema regressions for persisted OAuth connection metadata. +//! +//! Connection documents are versioned local authority metadata. Unknown fields must never be +//! silently discarded because that could reinterpret a newer or forged authority shape as the +//! currently supported schema. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{load_connections, requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn make_private(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + +fn write_private(path: &std::path::Path, value: serde_json::Value) { + std::fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap(); + make_private(path); +} + +fn root() -> CloudRoot { + #[cfg(windows)] + let path = r"C:\Cloud\GoogleDrive".to_string(); + #[cfg(not(windows))] + let path = "/Cloud/GoogleDrive".to_string(); + + CloudRoot { + id: "google-drive:unknown-field-regression".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn connection_id(root: &CloudRoot) -> String { + let mut hasher = Sha256::new(); + for value in [root.provider.as_str(), root.id.as_str(), root.path.as_str()] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn connection(root: &CloudRoot) -> OAuthConnection { + OAuthConnection { + connection_id: connection_id(root), + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms: 1, + } +} + +#[test] +fn connection_document_rejects_unknown_top_level_authority_fields() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("connections.json"); + write_private( + &path, + serde_json::json!({ + "version": 1, + "connections": [], + "unexpected_authority": "must-not-be-ignored" + }), + ); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-document-invalid" + ); +} + +#[test] +fn connection_document_rejects_unknown_nested_connection_authority_fields() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("connections.json"); + let root = root(); + let mut encoded = serde_json::to_value(connection(&root)).unwrap(); + encoded + .as_object_mut() + .unwrap() + .insert("write_authority".into(), serde_json::Value::Bool(true)); + write_private( + &path, + serde_json::json!({ + "version": 1, + "connections": [encoded] + }), + ); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-document-invalid" + ); +} From 84151bf2009c18e0fcd17ebd73fd7bd48020c370 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:09:46 +0900 Subject: [PATCH 028/157] fix(security): reject unknown OAuth document fields --- src-tauri/src/provider_oauth.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index e464a56c5..d4564d1d0 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -47,6 +47,7 @@ const GOOGLE_READ_SCOPE: &str = "https://www.googleapis.com/auth/drive.metadata. const GOOGLE_WRITE_SCOPE: &str = "https://www.googleapis.com/auth/drive"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct OAuthConnection { pub connection_id: String, pub provider: CloudProvider, @@ -58,6 +59,7 @@ pub struct OAuthConnection { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] struct ConnectionDocument { version: u32, connections: Vec, From 1728e74c183687472048319d1278e5895db04c2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:10:55 +0900 Subject: [PATCH 029/157] test(security): reject duplicate durable OAuth identities --- ...vider_oauth_duplicate_identity_coverage.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_duplicate_identity_coverage.rs diff --git a/src-tauri/tests/provider_oauth_duplicate_identity_coverage.rs b/src-tauri/tests/provider_oauth_duplicate_identity_coverage.rs new file mode 100644 index 000000000..2418edb9d --- /dev/null +++ b/src-tauri/tests/provider_oauth_duplicate_identity_coverage.rs @@ -0,0 +1,80 @@ +//! Regression coverage for duplicate durable OAuth connection identities. +//! +//! A connection id is also the credential-store lookup key. Persisting the exact same id twice +//! makes one credential identity correspond to multiple connection records and defers the failure +//! until root lookup. The connection-document parser must reject that ambiguity at the durable +//! evidence boundary while continuing to allow distinct canonical/legacy ids for migration. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{load_connections, requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use unicode_normalization::UnicodeNormalization; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn canonical_connection_id(root: &CloudRoot) -> String { + let root_id = root.id.nfc().collect::(); + let root_path = root.path.nfc().collect::(); + let mut hasher = Sha256::new(); + for value in [root.provider.as_str(), root_id.as_str(), root_path.as_str()] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn private_write(path: &std::path::Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + +#[test] +fn duplicate_durable_connection_id_is_rejected_at_document_load() { + let temp = tempfile::tempdir().unwrap(); + #[cfg(windows)] + let path = r"C:\Cloud\Drive".to_string(); + #[cfg(not(windows))] + let path = "/Cloud/Drive".to_string(); + let root = CloudRoot { + id: "google-drive:account".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + }; + let connection = OAuthConnection { + connection_id: canonical_connection_id(&root), + provider: root.provider, + cloud_root_id: root.id, + cloud_root_path: root.path, + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(CloudProvider::GoogleDrive).unwrap().into(), + connected_at_ms: 123, + }; + let document_path = temp.path().join("connections.json"); + private_write( + &document_path, + &serde_json::to_vec(&serde_json::json!({ + "version": 1, + "connections": [connection.clone(), connection] + })) + .unwrap(), + ); + + assert_eq!( + load_connections(&document_path).unwrap_err(), + "oauth-connection-document-duplicate-id" + ); +} From 037a4fc5d77dbdcc5938096ea320e08d34f89fc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:13:51 +0900 Subject: [PATCH 030/157] fix(security): reject duplicate durable OAuth identities --- src-tauri/src/provider_oauth.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index d4564d1d0..b3941351b 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -315,6 +315,16 @@ fn validate_connection(connection: &OAuthConnection) -> Result<(), String> { Ok(()) } +fn validate_unique_connection_ids(connections: &[OAuthConnection]) -> Result<(), String> { + let mut connection_ids = std::collections::BTreeSet::new(); + for connection in connections { + if !connection_ids.insert(connection.connection_id.as_str()) { + return Err("oauth-connection-document-duplicate-id".into()); + } + } + Ok(()) +} + fn connection_matches_root(connection: &OAuthConnection, root: &CloudRoot) -> bool { validate_connection(connection).is_ok() && connection.provider == root.provider @@ -352,6 +362,7 @@ pub fn load_connections(path: &Path) -> Result, String> { for connection in &document.connections { validate_connection(connection)?; } + validate_unique_connection_ids(&document.connections)?; Ok(document.connections) } @@ -362,6 +373,7 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), for connection in connections { validate_connection(connection)?; } + validate_unique_connection_ids(connections)?; let parent = path .parent() .ok_or_else(|| "oauth-connection-directory-invalid".to_string())?; From 9dcde2c483201bf3e1ca8840a0b9f38ee29ffdcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:14:24 +0900 Subject: [PATCH 031/157] test(security): reject shared-writable OAuth parents --- ...ovider_oauth_parent_permission_coverage.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_parent_permission_coverage.rs diff --git a/src-tauri/tests/provider_oauth_parent_permission_coverage.rs b/src-tauri/tests/provider_oauth_parent_permission_coverage.rs new file mode 100644 index 000000000..7ba91ff88 --- /dev/null +++ b/src-tauri/tests/provider_oauth_parent_permission_coverage.rs @@ -0,0 +1,37 @@ +//! Unix filesystem-authority coverage for OAuth connection metadata persistence. +//! +//! The connection registry selects the local OAuth metadata associated with refresh-token +//! credentials. Its immediate parent therefore must not give group or other principals directory +//! entry replacement authority, even when the document itself is a private regular file. + +#[cfg(unix)] +#[test] +fn connection_document_rejects_group_and_other_writable_parent() { + use disksage_lib::provider_oauth::load_connections; + use std::os::unix::fs::PermissionsExt; + + for writable_bit in [0o020, 0o002] { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join(format!("oauth-parent-{writable_bit:o}")); + std::fs::create_dir(&parent).unwrap(); + let path = parent.join("connections.json"); + let original = b"{\"version\":1,\"connections\":[]}"; + std::fs::write(&path, original).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions( + &parent, + std::fs::Permissions::from_mode(0o700 | writable_bit), + ) + .unwrap(); + + let result = load_connections(&path); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + result.unwrap_err(), + "oauth-connection-directory-writable-by-others", + "Unix connection metadata must fail closed when parent mode includes {writable_bit:o}" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } +} From ea66c2b1718756258384f04da123f435f3fe36bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:14:33 +0900 Subject: [PATCH 032/157] test(security): require private OAuth metadata files --- ...provider_oauth_leaf_permission_coverage.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_leaf_permission_coverage.rs diff --git a/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs b/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs new file mode 100644 index 000000000..4dc7ad7cc --- /dev/null +++ b/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs @@ -0,0 +1,30 @@ +//! Unix privacy-boundary coverage for durable OAuth connection metadata. +//! +//! A connection document contains provider, client, scope, and cloud-root identity metadata. +//! DiskSage writes new documents as mode 0600 on Unix, so loading a pre-existing document must +//! fail closed if any group or other permission bit exposes or weakens that local metadata. + +#[cfg(unix)] +#[test] +fn connection_document_requires_private_leaf_permissions() { + use disksage_lib::provider_oauth::load_connections; + use std::os::unix::fs::PermissionsExt; + + for mode in [0o640, 0o604, 0o620, 0o602] { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join(format!("oauth-private-parent-{mode:o}")); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = parent.join("connections.json"); + let original = b"{\"version\":1,\"connections\":[]}"; + std::fs::write(&path, original).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-document-permissions-unsafe", + "mode {mode:o} must not be admitted as private OAuth metadata" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } +} From 2e5de502ed51625de3edd33bb0323359ef57cdbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:14:43 +0900 Subject: [PATCH 033/157] test(security): reject symlinked OAuth parent authority --- .../provider_oauth_parent_symlink_coverage.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_parent_symlink_coverage.rs diff --git a/src-tauri/tests/provider_oauth_parent_symlink_coverage.rs b/src-tauri/tests/provider_oauth_parent_symlink_coverage.rs new file mode 100644 index 000000000..f9c24b385 --- /dev/null +++ b/src-tauri/tests/provider_oauth_parent_symlink_coverage.rs @@ -0,0 +1,32 @@ +//! Unix ancestor-authority regression for durable OAuth connection metadata. +//! +//! Rejecting a symlink only at the leaf is insufficient: a symlinked application-data ancestor +//! can redirect a syntactically ordinary connection path to attacker-controlled storage. + +#[cfg(unix)] +#[test] +fn connection_document_rejects_symlinked_parent_ancestor() { + use disksage_lib::provider_oauth::load_connections; + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let outside = temp.path().join("outside"); + let nested = outside.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o700)).unwrap(); + let document = nested.join("connections.json"); + let original = b"{\"version\":1,\"connections\":[]}"; + std::fs::write(&document, original).unwrap(); + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let alias = temp.path().join("app-data-alias"); + symlink(&outside, &alias).unwrap(); + let path = alias.join("nested").join("connections.json"); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-directory-unsafe" + ); + assert_eq!(std::fs::read(&document).unwrap(), original); +} From 3dc8bc59840df02f32ee9c268a0a9656cc285d6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:17:35 +0900 Subject: [PATCH 034/157] fix(security): bind OAuth metadata to private directory authority --- src-tauri/src/provider_oauth.rs | 114 +++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index b3941351b..c2c048384 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -339,7 +339,45 @@ pub fn connections_path(app_data_dir: &Path) -> PathBuf { app_data_dir.join("cloud-oauth-connections.json") } +fn connection_document_parent(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn validate_connection_document_parent(parent: &Path, allow_missing: bool) -> Result<(), String> { + for ancestor in parent + .ancestors() + .filter(|ancestor| !ancestor.as_os_str().is_empty()) + { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("oauth-connection-directory-unsafe".into()); + } + Ok(metadata) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = metadata.permissions().mode(); + let shared_writable = mode & 0o022 != 0; + let sticky = mode & 0o1000 != 0; + if shared_writable && (ancestor == parent || !sticky) { + return Err("oauth-connection-directory-writable-by-others".into()); + } + } + } + Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => { + return Err("oauth-connection-directory-unsafe".into()); + } + Err(_) => return Err("oauth-connection-directory-unavailable".into()), + } + } + Ok(()) +} + pub fn load_connections(path: &Path) -> Result, String> { + validate_connection_document_parent(connection_document_parent(path), true)?; let metadata = match std::fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), @@ -348,6 +386,13 @@ pub fn load_connections(path: &Path) -> Result, String> { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("oauth-connection-document-not-regular-file".into()); } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err("oauth-connection-document-permissions-unsafe".into()); + } + } if metadata.len() > MAX_CONNECTION_DOCUMENT_BYTES { return Err("oauth-connection-document-too-large".into()); } @@ -374,10 +419,10 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), validate_connection(connection)?; } validate_unique_connection_ids(connections)?; - let parent = path - .parent() - .ok_or_else(|| "oauth-connection-directory-invalid".to_string())?; + let parent = connection_document_parent(path); + validate_connection_document_parent(parent, true)?; std::fs::create_dir_all(parent).map_err(|_| "oauth-connection-directory-unavailable")?; + validate_connection_document_parent(parent, false)?; if let Ok(metadata) = std::fs::symlink_metadata(path) { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("oauth-connection-document-not-regular-file".into()); @@ -1289,6 +1334,69 @@ mod tests { assert!(save_connections(&link, &[]).is_err()); } + #[cfg(unix)] + #[test] + fn connection_document_rejects_symlinked_directory_ancestors_for_read_and_write() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let outside = temp.path().join("outside-ancestor"); + let outside_parent = outside.join("nested"); + std::fs::create_dir_all(&outside_parent).unwrap(); + std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&outside_parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let outside_document = outside_parent.join("connections.json"); + let original = b"{\"version\":1,\"connections\":[]}"; + std::fs::write(&outside_document, original).unwrap(); + std::fs::set_permissions(&outside_document, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let alias = temp.path().join("app-data-alias"); + symlink(&outside, &alias).unwrap(); + let path = alias.join("nested").join("connections.json"); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-directory-unsafe" + ); + assert_eq!( + save_connections(&path, &[]).unwrap_err(), + "oauth-connection-directory-unsafe" + ); + assert_eq!(std::fs::read(&outside_document).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn connection_document_rejects_shared_writable_parent_for_write() { + use std::os::unix::fs::PermissionsExt; + + for writable_bit in [0o020, 0o002] { + let temp = tempfile::tempdir().unwrap(); + let parent = temp + .path() + .join(format!("oauth-write-parent-{writable_bit:o}")); + std::fs::create_dir(&parent).unwrap(); + let path = parent.join("connections.json"); + let original = b"{\"version\":1,\"connections\":[]}"; + std::fs::write(&path, original).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions( + &parent, + std::fs::Permissions::from_mode(0o700 | writable_bit), + ) + .unwrap(); + + let result = save_connections(&path, &[]); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + result.unwrap_err(), + "oauth-connection-directory-writable-by-others" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + } + #[test] fn token_values_and_percent_codec_are_bounded() { assert!(validate_token_value("token-value")); From dd9b265733bfd6a6c84aacdaf4804311cc019c9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:18:32 +0900 Subject: [PATCH 035/157] test(security): bound OAuth document publication --- ...nection_document_write_bound_regression.rs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs diff --git a/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs new file mode 100644 index 000000000..308224dfc --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs @@ -0,0 +1,55 @@ +#![allow(dead_code, unused_imports)] + +// Compile the production OAuth module into this integration-test crate so the regression can +// exercise its private persistence boundary without widening the shipped API surface. +include!("../src/provider_oauth.rs"); + +// The included production module resolves `crate::cloud`; re-export the shipped cloud types under +// the same crate-local path while keeping the test credential-free and network-free. +mod cloud { + pub use disksage_lib::cloud::*; +} + +#[test] +fn connection_document_writer_rejects_oversized_payload_before_publication() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("first-use-app-data"); + let path = parent.join("connections.json"); + + #[cfg(windows)] + let cloud_path = r"C:\Cloud".to_string(); + #[cfg(not(windows))] + let cloud_path = "/Cloud".to_string(); + + let root = CloudRoot { + id: "x".repeat(MAX_CONNECTION_DOCUMENT_BYTES as usize + 1024), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Cloud".into(), + path: cloud_path, + readable: true, + access_issue: None, + }; + let connection = OAuthConnection { + connection_id: connection_id(&root), + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms: 123, + }; + + assert_eq!( + save_connections(&path, &[connection]).unwrap_err(), + "oauth-connection-document-too-large" + ); + assert!( + !parent.exists(), + "rejected oversized metadata must not create its first-use authority directory" + ); + assert!( + !path.exists(), + "rejected oversized metadata must not publish an unreadable durable document" + ); +} From 9f5ce2970bd5bbf6f8d362544fa39eae184591b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:29:12 +0900 Subject: [PATCH 036/157] fix(security): bound OAuth document publication before filesystem mutation --- src-tauri/src/provider_oauth.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index c2c048384..9556a9981 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -419,6 +419,15 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), validate_connection(connection)?; } validate_unique_connection_ids(connections)?; + let document = ConnectionDocument { + version: CONNECTION_DOCUMENT_VERSION, + connections: connections.to_vec(), + }; + let encoded = serde_json::to_vec_pretty(&document) + .map_err(|_| "oauth-connection-document-encode-failed")?; + if encoded.len() as u64 > MAX_CONNECTION_DOCUMENT_BYTES { + return Err("oauth-connection-document-too-large".into()); + } let parent = connection_document_parent(path); validate_connection_document_parent(parent, true)?; std::fs::create_dir_all(parent).map_err(|_| "oauth-connection-directory-unavailable")?; @@ -428,12 +437,6 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), return Err("oauth-connection-document-not-regular-file".into()); } } - let document = ConnectionDocument { - version: CONNECTION_DOCUMENT_VERSION, - connections: connections.to_vec(), - }; - let encoded = serde_json::to_vec_pretty(&document) - .map_err(|_| "oauth-connection-document-encode-failed")?; let temporary = parent.join(format!( ".cloud-oauth-connections.{}.tmp", random_urlsafe(12)? @@ -1160,6 +1163,7 @@ mod tests { &state, ) .unwrap(); + assert!(google.starts_with(ONEDRIVE_AUTH_ENDPOINT) == false); assert!(google.starts_with(GOOGLE_AUTH_ENDPOINT)); assert!(google.contains("drive.metadata.readonly")); assert!(google.contains("access_type=offline")); From a56a3d98047ba835c45898280462d1a7fd4cdd0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:33:31 +0900 Subject: [PATCH 037/157] test(security): preserve OAuth retry credential on stale disconnect failure --- ...sconnect_stale_delete_rollback_coverage.rs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs diff --git a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs new file mode 100644 index 000000000..ff1e83cbc --- /dev/null +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -0,0 +1,87 @@ +#![allow(dead_code, unused_imports)] + +//! A disconnect must not destroy the canonical refresh token before every stale matching +//! credential has been removed. The durable connection document can be restored after a delete +//! failure, but a successfully deleted canonical credential cannot be recreated from that file. +//! Delete stale legacy credentials first so a stale-delete failure leaves the canonical retry +//! credential intact and the restored document remains an honest recovery handle. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn unicode_google_root(decomposed: bool) -> CloudRoot { + #[cfg(windows)] + let composed = r"C:\Cloud\내 드라이브"; + #[cfg(not(windows))] + let composed = "/Cloud/내 드라이브"; + let path = if decomposed { + composed.nfd().collect::() + } else { + composed.to_string() + }; + CloudRoot { + id: path.clone(), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn google_connection( + root: &CloudRoot, + connection_id: String, + connected_at_ms: u64, +) -> OAuthConnection { + OAuthConnection { + connection_id, + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms, + } +} + +#[test] +fn stale_credential_delete_failure_preserves_canonical_retry_credential() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let current = google_connection(&saved_root, connection_id(&saved_root), 200); + assert_ne!(legacy.connection_id, current.connection_id); + let original = vec![legacy.clone(), current.clone()]; + save_connections(&document, &original).unwrap(); + + let mut deleted = Vec::new(); + let error = disconnect_with_delete(&document, &requested_root, |connection_id| { + deleted.push(connection_id.to_string()); + if connection_id == legacy.connection_id { + Err("provider-oauth-keyring-delete-failed".to_string()) + } else { + Ok(()) + } + }) + .unwrap_err(); + + assert_eq!(error, "provider-oauth-keyring-delete-failed"); + assert_eq!( + deleted, + vec![legacy.connection_id], + "stale matching credentials must be removed before the canonical credential so a stale-delete failure cannot destroy the only usable retry credential" + ); + assert_eq!( + load_connections(&document).unwrap(), + original, + "a partial credential cleanup must restore durable connection state so a retry can finish deleting every matching credential" + ); +} From 5bc16fec3a6d05901ce84d14bf7200f04cf8d996 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:33:57 +0900 Subject: [PATCH 038/157] test(security): remove all matching OAuth identities on disconnect --- ..._oauth_disconnect_all_matching_coverage.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs diff --git a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs new file mode 100644 index 000000000..bac06b23e --- /dev/null +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -0,0 +1,71 @@ +#![allow(dead_code, unused_imports)] + +//! Disconnecting one canonical File Provider root must remove every durable canonical/legacy +//! record that identifies that same root. Leaving a legacy record behind would preserve a local +//! connection and credential lookup path after the user was told the provider was disconnected. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn unicode_google_root(decomposed: bool) -> CloudRoot { + #[cfg(windows)] + let composed = r"C:\Cloud\내 드라이브"; + #[cfg(not(windows))] + let composed = "/Cloud/내 드라이브"; + let path = if decomposed { + composed.nfd().collect::() + } else { + composed.to_string() + }; + CloudRoot { + id: path.clone(), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn google_connection( + root: &CloudRoot, + connection_id: String, + connected_at_ms: u64, +) -> OAuthConnection { + OAuthConnection { + connection_id, + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms, + } +} + +#[test] +fn disconnect_removes_every_canonical_and_legacy_record_for_the_same_root() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let current = google_connection(&saved_root, connection_id(&saved_root), 200); + assert_ne!(legacy.connection_id, current.connection_id); + save_connections(&document, &[legacy.clone(), current.clone()]).unwrap(); + + let mut deleted = Vec::new(); + disconnect_with_delete(&document, &requested_root, |connection_id| { + deleted.push(connection_id.to_string()); + Ok(()) + }) + .unwrap(); + + assert!(load_connections(&document).unwrap().is_empty()); + assert_eq!(deleted, vec![legacy.connection_id, current.connection_id]); +} From a7b16464d80eea0a3eac083f96a68ebe3fc8fd9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:36:32 +0900 Subject: [PATCH 039/157] fix(security): disconnect every matching OAuth credential safely --- src-tauri/src/provider_oauth.rs | 36 ++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 9556a9981..9bf1018f8 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -1037,17 +1037,39 @@ pub fn refreshed_access_token( Ok(grant.access_token) } -#[cfg(not(coverage))] -pub fn disconnect(connection_document_path: &Path, root: &CloudRoot) -> Result<(), String> { +fn disconnect_with_delete( + connection_document_path: &Path, + root: &CloudRoot, + mut delete_token: F, +) -> Result<(), String> +where + F: FnMut(&str) -> Result<(), String>, +{ let original = load_connections(connection_document_path)?; let connection = connection_for_root(&original, root)?; + let matching_ids: Vec<_> = original + .iter() + .filter(|entry| connection_matches_root(entry, root)) + .map(|entry| entry.connection_id.clone()) + .collect(); let updated: Vec<_> = original .iter() - .filter(|entry| entry.connection_id != connection.connection_id) + .filter(|entry| !connection_matches_root(entry, root)) .cloned() .collect(); save_connections(connection_document_path, &updated)?; - if let Err(error) = delete_refresh_token(&connection.connection_id) { + for stale_id in matching_ids + .iter() + .filter(|connection_id| **connection_id != connection.connection_id) + { + if let Err(error) = delete_token(stale_id) { + if save_connections(connection_document_path, &original).is_err() { + return Err("provider-oauth-keyring-delete-and-config-rollback-failed".into()); + } + return Err(error); + } + } + if let Err(error) = delete_token(&connection.connection_id) { if save_connections(connection_document_path, &original).is_err() { return Err("provider-oauth-keyring-delete-and-config-rollback-failed".into()); } @@ -1056,6 +1078,11 @@ pub fn disconnect(connection_document_path: &Path, root: &CloudRoot) -> Result<( Ok(()) } +#[cfg(not(coverage))] +pub fn disconnect(connection_document_path: &Path, root: &CloudRoot) -> Result<(), String> { + disconnect_with_delete(connection_document_path, root, delete_refresh_token) +} + #[cfg(test)] mod tests { use super::*; @@ -1163,7 +1190,6 @@ mod tests { &state, ) .unwrap(); - assert!(google.starts_with(ONEDRIVE_AUTH_ENDPOINT) == false); assert!(google.starts_with(GOOGLE_AUTH_ENDPOINT)); assert!(google.contains("drive.metadata.readonly")); assert!(google.contains("access_type=offline")); From 31a52f598aa6b332102badea7bb9cf82db53db64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:38:02 +0900 Subject: [PATCH 040/157] test(security): reject incomplete OAuth loopback framing --- ...auth_loopback_callback_failure_coverage.rs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_loopback_callback_failure_coverage.rs diff --git a/src-tauri/tests/provider_oauth_loopback_callback_failure_coverage.rs b/src-tauri/tests/provider_oauth_loopback_callback_failure_coverage.rs new file mode 100644 index 000000000..b9ab57eff --- /dev/null +++ b/src-tauri/tests/provider_oauth_loopback_callback_failure_coverage.rs @@ -0,0 +1,200 @@ +//! Credential-free coverage for rejected OAuth loopback callbacks. +//! +//! This drives the real public `finish_authorization` boundary through the ephemeral loopback +//! listener, exercises malformed/unauthorized callback handling, and terminates on an explicit +//! authorization denial before any provider network, keyring, or durable-connection mutation. + +#![cfg(not(coverage))] + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{ + connections_path, finish_authorization, prepare_authorization, +}; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpStream}; +use std::time::Duration; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; +const CALLBACK_REQUEST_LIMIT_BYTES: usize = 16 * 1024; + +fn query_value<'a>(url: &'a str, key: &str) -> &'a str { + let query = url + .split_once('?') + .map(|(_, query)| query) + .expect("authorization URL has query parameters"); + query + .split('&') + .find_map(|pair| { + let (candidate_key, value) = pair.split_once('=')?; + (candidate_key == key).then_some(value) + }) + .unwrap_or_else(|| panic!("authorization URL is missing {key}")) +} + +fn google_loopback_port(url: &str) -> u16 { + const PREFIX: &str = "http%3A%2F%2F127.0.0.1%3A"; + query_value(url, "redirect_uri") + .strip_prefix(PREFIX) + .expect("Google redirect URI uses the registered loopback IP form") + .parse() + .expect("loopback port is numeric") +} + +fn send_raw_rejected_bytes(port: u16, request: &[u8]) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("loopback listener accepts"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("read timeout configured"); + stream + .write_all(request) + .expect("callback request written"); + stream.flush().expect("callback request flushed"); + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("bounded loopback response read"); + assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(response.contains("Cache-Control: no-store\r\n")); + assert!(response.contains("Content-Security-Policy")); + response +} + +fn send_raw_rejected_request(port: u16, request: &str) -> String { + send_raw_rejected_bytes(port, request.as_bytes()) +} + +fn send_eof_terminated_rejected_request(port: u16, request_line: &str) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("loopback listener accepts"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("read timeout configured"); + stream + .write_all(format!("{request_line}\r\n").as_bytes()) + .expect("partial callback request written"); + stream.flush().expect("partial callback request flushed"); + stream + .shutdown(Shutdown::Write) + .expect("partial callback request write side closes"); + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("bounded loopback response read"); + assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(response.contains("Cache-Control: no-store\r\n")); + assert!(response.contains("Content-Security-Policy")); + response +} + +fn send_rejected_request(port: u16, request_line: &str) -> String { + let request = format!( + "{request_line}\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + send_raw_rejected_request(port, &request) +} + +fn exact_limit_rejected_request(port: u16) -> String { + let prefix = "GET /?padding="; + let suffix = format!( + " HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + let padding_bytes = CALLBACK_REQUEST_LIMIT_BYTES + .checked_sub(prefix.len() + suffix.len()) + .expect("callback limit exceeds fixed request framing"); + let request = format!("{prefix}{}{suffix}", "a".repeat(padding_bytes)); + assert_eq!(request.len(), CALLBACK_REQUEST_LIMIT_BYTES); + request +} + +fn invalid_utf8_callback_request(port: u16, state: &str) -> Vec { + let mut request = b"GET /?code=".to_vec(); + request.push(0xff); + request.extend_from_slice( + format!( + "&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ); + request +} + +#[test] +fn rejected_callbacks_fail_closed_before_network_keyring_or_durable_publication() { + let temp = tempfile::tempdir().unwrap(); + let connection_path = connections_path(temp.path()); + + #[cfg(windows)] + let root_path = r"C:\Cloud\google-account"; + #[cfg(not(windows))] + let root_path = "/Cloud/google-account"; + + let root = CloudRoot { + id: "google-account".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: "Google Drive".into(), + path: root_path.into(), + readable: true, + access_issue: None, + }; + + let pending = prepare_authorization(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID).unwrap(); + let authorization_url = pending.authorization_url().to_owned(); + let port = google_loopback_port(&authorization_url); + let state = query_value(&authorization_url, "state").to_owned(); + + let worker = std::thread::spawn(move || { + finish_authorization(pending, &root, &connection_path, 123) + }); + + send_raw_rejected_request(port, &exact_limit_rejected_request(port)); + send_raw_rejected_bytes(port, &invalid_utf8_callback_request(port, &state)); + send_eof_terminated_rejected_request( + port, + &format!("GET /?error=access_denied&state={state} HTTP/1.1"), + ); + send_rejected_request(port, "GET HTTP/1.1"); + send_rejected_request( + port, + &format!("GET /?code=ignored&state={state} HTTP/1.1 EXTRA"), + ); + send_rejected_request( + port, + &format!("POST /?code=ignored&state={state} HTTP/1.1"), + ); + send_rejected_request( + port, + &format!("GET /wrong?code=ignored&state={state} HTTP/1.1"), + ); + send_rejected_request(port, &format!("GET /?state={state} HTTP/1.1")); + send_rejected_request( + port, + &format!("GET /?code=one&code=two&state={state} HTTP/1.1"), + ); + send_rejected_request( + port, + &format!("GET /?code=one&state={state}&state={state} HTTP/1.1"), + ); + send_rejected_request(port, "GET /?error=access_denied HTTP/1.1"); + send_rejected_request(port, &format!("GET /?code=&state={state} HTTP/1.1")); + send_rejected_request( + port, + &format!("GET /?code=%00&state={state} HTTP/1.1"), + ); + send_rejected_request( + port, + &format!("GET /?code=%GG&state={state} HTTP/1.1"), + ); + send_rejected_request(port, "GET /?code=ignored&state=wrong HTTP/1.1"); + send_rejected_request( + port, + &format!("GET /?error=access_denied&state={state} HTTP/1.1"), + ); + + assert_eq!( + worker.join().expect("authorization worker joins").unwrap_err(), + "oauth-authorization-denied" + ); + assert!(!connections_path(temp.path()).exists()); +} From 456c80de2c61f19f6ebc6d614606cf18ae02a16b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 03:40:17 +0900 Subject: [PATCH 041/157] fix(security): require complete OAuth loopback HTTP framing --- src-tauri/src/provider_oauth.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 9bf1018f8..1b4d84188 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -694,6 +694,9 @@ fn read_callback_target(stream: &mut TcpStream) -> Result { if request.len() >= MAX_CALLBACK_REQUEST_BYTES { return Err("oauth-callback-request-too-large".into()); } + if !request.windows(4).any(|window| window == b"\r\n\r\n") { + return Err("oauth-callback-request-invalid".into()); + } let request = std::str::from_utf8(&request).map_err(|_| "oauth-callback-request-invalid")?; let first_line = request .split("\r\n") From 6f223cb8eff5f0f81cfe2e7ffc7899581779f88c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:05:53 +0900 Subject: [PATCH 042/157] test(oauth): inherit durable publication authority regressions --- ...ction_document_write_authority_coverage.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs diff --git a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs new file mode 100644 index 000000000..1e226eb3f --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -0,0 +1,188 @@ +#![allow(dead_code, unused_imports)] + +//! Credential-free coverage for durable OAuth connection publication. +//! +//! The writer is intentionally private: callers reach it through the bounded OAuth lifecycle. +//! Including the production module here lets these regressions exercise the real local persistence +//! boundary without widening the shipped API, opening a browser, contacting a provider, or touching +//! the credential store. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn google_root(id: &str) -> CloudRoot { + #[cfg(windows)] + let path = format!(r"C:\Cloud\{id}"); + #[cfg(not(windows))] + let path = format!("/Cloud/{id}"); + + CloudRoot { + id: format!("google-drive:{id}"), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn connection(id: &str, connected_at_ms: u64) -> OAuthConnection { + let root = google_root(id); + OAuthConnection { + connection_id: connection_id(&root), + provider: root.provider, + cloud_root_id: root.id, + cloud_root_path: root.path, + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(CloudProvider::GoogleDrive).unwrap().into(), + connected_at_ms, + } +} + +#[test] +fn valid_publication_is_private_loadable_and_replaceable() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("first-use-app-data").join("connections.json"); + let first = connection("account-a", 123); + + save_connections(&path, std::slice::from_ref(&first)).unwrap(); + assert_eq!(load_connections(&path).unwrap(), vec![first.clone()]); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "durable OAuth metadata must be private at creation time" + ); + } + + let mut replacement = first; + replacement.connected_at_ms = 456; + save_connections(&path, std::slice::from_ref(&replacement)).unwrap(); + assert_eq!( + load_connections(&path).unwrap(), + vec![replacement], + "replacement must publish the complete new document rather than preserve stale metadata" + ); +} + +#[cfg(unix)] +#[test] +fn shared_writable_parent_never_gains_publication_authority() { + use std::os::unix::fs::PermissionsExt; + + for writable_bit in [0o020, 0o002] { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join(format!("oauth-write-parent-{writable_bit:o}")); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions( + &parent, + std::fs::Permissions::from_mode(0o700 | writable_bit), + ) + .unwrap(); + let path = parent.join("connections.json"); + + let result = save_connections(&path, &[connection("account", 1)]); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + result.unwrap_err(), + "oauth-connection-directory-writable-by-others", + "writable bit {writable_bit:o} must fail before durable OAuth metadata is published" + ); + assert!( + !path.exists(), + "a rejected authority directory must not receive a connection document" + ); + assert_eq!( + std::fs::read_dir(&parent).unwrap().count(), + 0, + "authority rejection must not leave a temporary OAuth document behind" + ); + } +} + +#[test] +fn invalid_sets_fail_before_first_use_authority_is_created() { + let temp = tempfile::tempdir().unwrap(); + + let duplicate_parent = temp.path().join("duplicate-first-use"); + let duplicate_path = duplicate_parent.join("connections.json"); + let duplicate = connection("duplicate", 1); + assert_eq!( + save_connections(&duplicate_path, &[duplicate.clone(), duplicate]).unwrap_err(), + "oauth-connection-document-duplicate-id" + ); + assert!( + !duplicate_parent.exists(), + "duplicate identities must be rejected before creating the durable authority directory" + ); + + let count_parent = temp.path().join("count-first-use"); + let count_path = count_parent.join("connections.json"); + let too_many: Vec<_> = (0..=MAX_CONNECTIONS) + .map(|index| connection(&format!("account-{index}"), index as u64)) + .collect(); + assert_eq!( + save_connections(&count_path, &too_many).unwrap_err(), + "oauth-connection-count-invalid" + ); + assert!( + !count_parent.exists(), + "an over-capacity document must not create its first-use authority directory" + ); +} + +#[test] +fn an_existing_non_regular_destination_never_gains_publication_authority() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("connections.json"); + std::fs::create_dir(&path).unwrap(); + + assert_eq!( + save_connections(&path, &[connection("account", 1)]).unwrap_err(), + "oauth-connection-document-not-regular-file" + ); + assert!(path.is_dir(), "rejected destination must remain untouched"); +} + +#[cfg(unix)] +#[test] +fn a_symlink_destination_never_gains_publication_authority_or_mutates_its_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("external-sensitive.json"); + let original = b"external-sensitive-bytes"; + std::fs::write(&target, original).unwrap(); + let path = temp.path().join("connections.json"); + symlink(&target, &path).unwrap(); + + assert_eq!( + save_connections(&path, &[connection("account", 1)]).unwrap_err(), + "oauth-connection-document-not-regular-file" + ); + assert!( + std::fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink(), + "rejected OAuth destination must remain a symlink rather than being replaced" + ); + assert_eq!( + std::fs::read(&target).unwrap(), + original, + "a rejected symlink destination must not mutate its target" + ); + assert_eq!( + std::fs::read_dir(temp.path()).unwrap().count(), + 2, + "symlink rejection must not leave a temporary OAuth document behind" + ); +} From b31bff4ba145982de0945287bd2d0d10ac3bdd9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 04:06:08 +0900 Subject: [PATCH 043/157] test(oauth): inherit canonical legacy identity migration regressions --- ...der_oauth_connection_migration_coverage.rs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_migration_coverage.rs diff --git a/src-tauri/tests/provider_oauth_connection_migration_coverage.rs b/src-tauri/tests/provider_oauth_connection_migration_coverage.rs new file mode 100644 index 000000000..f6dd29204 --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_migration_coverage.rs @@ -0,0 +1,133 @@ +//! Credential-free migration coverage for persisted OAuth connection identity selection. +//! +//! Version-1 connection documents hashed the raw filesystem spelling while current identities +//! normalize File Provider roots to NFC. These tests exercise the public lookup boundary with +//! realistic legacy/current records and no browser, keyring, provider, or network access. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{connection_for_root, requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use unicode_normalization::UnicodeNormalization; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn root(path: String) -> CloudRoot { + CloudRoot { + id: path.clone(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Organization, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn connection_id_for_values(provider: &str, root_id: &str, root_path: &str) -> String { + let mut hasher = Sha256::new(); + for value in [provider, root_id, root_path] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn canonical_connection_id(root: &CloudRoot) -> String { + let root_id = root.id.nfc().collect::(); + let root_path = root.path.nfc().collect::(); + connection_id_for_values(root.provider.as_str(), &root_id, &root_path) +} + +fn legacy_connection_id(root: &CloudRoot) -> String { + connection_id_for_values(root.provider.as_str(), &root.id, &root.path) +} + +fn connection(root: &CloudRoot, connection_id: String, connected_at_ms: u64) -> OAuthConnection { + OAuthConnection { + connection_id, + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms, + } +} + +fn equivalent_roots() -> (CloudRoot, CloudRoot) { + #[cfg(windows)] + let composed = r"C:\Cloud\내 드라이브"; + #[cfg(not(windows))] + let composed = "/Cloud/내 드라이브"; + + let requested = root(composed.to_string()); + let legacy = root(composed.nfd().collect::()); + assert_ne!(requested.path, legacy.path); + assert_eq!( + requested.path.nfc().collect::(), + legacy.path.nfc().collect::() + ); + (requested, legacy) +} + +#[test] +fn canonical_record_wins_when_equivalent_legacy_record_is_still_present() { + let (requested, legacy_root) = equivalent_roots(); + let legacy = connection(&legacy_root, legacy_connection_id(&legacy_root), 100); + let current = connection(&legacy_root, canonical_connection_id(&legacy_root), 200); + assert_ne!(legacy.connection_id, current.connection_id); + + let selected = connection_for_root(&[legacy, current.clone()], &requested).unwrap(); + + assert_eq!(selected, current); + assert_eq!(selected.connected_at_ms, 200); +} + +#[test] +fn a_single_legacy_record_remains_usable_during_identity_migration() { + let (requested, legacy_root) = equivalent_roots(); + let legacy = connection(&legacy_root, legacy_connection_id(&legacy_root), 100); + + assert_eq!( + connection_for_root(std::slice::from_ref(&legacy), &requested).unwrap(), + legacy + ); +} + +#[test] +fn missing_connection_fails_closed() { + let (requested, _) = equivalent_roots(); + + assert_eq!( + connection_for_root(&[], &requested).unwrap_err(), + "provider-oauth-connection-missing" + ); +} + +#[test] +fn duplicate_canonical_records_remain_ambiguous_instead_of_gaining_authority() { + let (requested, legacy_root) = equivalent_roots(); + let current = connection(&legacy_root, canonical_connection_id(&legacy_root), 200); + + assert_eq!( + connection_for_root(&[current.clone(), current], &requested).unwrap_err(), + "provider-oauth-connection-ambiguous" + ); +} + +#[test] +fn duplicate_legacy_records_remain_ambiguous_instead_of_gaining_authority() { + let (requested, legacy_root) = equivalent_roots(); + let legacy = connection(&legacy_root, legacy_connection_id(&legacy_root), 100); + + assert_eq!( + connection_for_root(&[legacy.clone(), legacy], &requested).unwrap_err(), + "provider-oauth-connection-ambiguous" + ); +} From a6ca57f71cdc57bdf1f23c8f5dca10efef9e189a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:27:16 +0900 Subject: [PATCH 044/157] test(oauth): reject pre-delete Windows replacement --- ...r_oauth_windows_atomic_replace_contract.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs diff --git a/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs b/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs new file mode 100644 index 000000000..a3f4e2be3 --- /dev/null +++ b/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs @@ -0,0 +1,41 @@ +#![allow(dead_code, unused_imports)] + +//! Windows durable OAuth metadata replacement must never create a delete-before-publish window. +//! +//! `std::fs::rename` is the cross-platform publication primitive used by DiskSage and replaces an +//! existing regular destination on supported Windows filesystems. A separate `remove_file(path)` +//! before that call destroys the last known-good connection document if replacement then fails. +//! Keep the production writer on one replacement primitive and preserve the old document until the +//! new temporary document is ready to replace it. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +#[test] +fn production_writer_does_not_predelete_the_durable_destination() { + let source = include_str!("../src/provider_oauth.rs"); + let predelete = "#[cfg(windows)]\n if path.exists() {\n std::fs::remove_file(path)"; + + assert!( + !source.contains(predelete), + "Windows replacement must not delete the durable OAuth document before the replacement primitive runs" + ); +} + +#[cfg(windows)] +#[test] +fn windows_std_rename_replaces_an_existing_regular_file() { + let temp = tempfile::tempdir().unwrap(); + let durable = temp.path().join("connections.json"); + let replacement = temp.path().join("connections.tmp"); + std::fs::write(&durable, b"old-durable-document").unwrap(); + std::fs::write(&replacement, b"new-complete-document").unwrap(); + + std::fs::rename(&replacement, &durable).unwrap(); + + assert_eq!(std::fs::read(&durable).unwrap(), b"new-complete-document"); + assert!(!replacement.exists()); +} From 765716e87424dbc5582d8c8ceeb2c312f7a1e468 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:35:37 +0900 Subject: [PATCH 045/157] fix(oauth): preserve Windows document until replace --- src-tauri/src/provider_oauth.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 1b4d84188..cf9b8cb62 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -456,10 +456,6 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), let _ = std::fs::remove_file(&temporary); return Err("oauth-connection-document-write-failed".into()); } - #[cfg(windows)] - if path.exists() { - std::fs::remove_file(path).map_err(|_| "oauth-connection-document-replace-failed")?; - } if std::fs::rename(&temporary, path).is_err() { let _ = std::fs::remove_file(&temporary); return Err("oauth-connection-document-replace-failed".into()); From a7fdcd0a8c4af9688f279345e9fad6c6468f3914 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:38:16 +0900 Subject: [PATCH 046/157] test(oauth): keep failed legacy cleanup retry-visible --- ..._oauth_reauthorization_cleanup_coverage.rs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs new file mode 100644 index 000000000..caa149c23 --- /dev/null +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -0,0 +1,114 @@ +#![allow(dead_code, unused_imports)] + +//! A successful canonical refresh-token write must not make failed legacy cleanup invisible. +//! +//! Reauthorization can migrate an NFC/NFD legacy connection to the canonical identifier. If +//! deleting a legacy keyring credential fails after the canonical token has been stored, the +//! durable document must retain a retry-visible legacy identity while preferring the canonical +//! connection for normal use. Already-deleted legacy entries may also remain as retry handles: +//! keyring `NoEntry` is idempotent success on the next cleanup attempt. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn unicode_google_root(decomposed: bool) -> CloudRoot { + #[cfg(windows)] + let composed = r"C:\Cloud\내 드라이브"; + #[cfg(not(windows))] + let composed = "/Cloud/내 드라이브"; + let path = if decomposed { + composed.nfd().collect::() + } else { + composed.to_string() + }; + CloudRoot { + id: path.clone(), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn google_connection(root: &CloudRoot, connection_id: String, connected_at_ms: u64) -> OAuthConnection { + OAuthConnection { + connection_id, + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms, + } +} + +#[test] +fn failed_legacy_cleanup_restores_a_retry_visible_identity_beside_the_canonical_connection() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); + assert_ne!(legacy.connection_id, canonical.connection_id); + + let original = vec![legacy.clone()]; + save_connections(&document, std::slice::from_ref(&canonical)).unwrap(); + + let mut deleted = Vec::new(); + let error = cleanup_stale_authorization_credentials( + &document, + &requested_root, + &original, + &canonical, + |connection_id| { + deleted.push(connection_id.to_string()); + Err("provider-oauth-keyring-delete-failed".to_string()) + }, + ) + .unwrap_err(); + + assert_eq!(error, "provider-oauth-keyring-delete-failed"); + assert_eq!(deleted, vec![legacy.connection_id.clone()]); + let retry_visible = load_connections(&document).unwrap(); + assert!(retry_visible.contains(&legacy)); + assert!(retry_visible.contains(&canonical)); + assert_eq!( + connection_for_root(&retry_visible, &requested_root).unwrap(), + canonical, + "normal use must continue to prefer the newly stored canonical credential while the stale identity remains available for cleanup retry" + ); +} + +#[test] +fn successful_legacy_cleanup_keeps_the_published_document_canonical_only() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); + let original = vec![legacy.clone()]; + save_connections(&document, std::slice::from_ref(&canonical)).unwrap(); + + let mut deleted = Vec::new(); + cleanup_stale_authorization_credentials( + &document, + &requested_root, + &original, + &canonical, + |connection_id| { + deleted.push(connection_id.to_string()); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(deleted, vec![legacy.connection_id]); + assert_eq!(load_connections(&document).unwrap(), vec![canonical]); +} From 63cb5e2e0ffbbcc28665d399fdfe8ba912692529 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:41:43 +0900 Subject: [PATCH 047/157] fix(oauth): keep failed legacy cleanup retry-visible --- src-tauri/src/provider_oauth.rs | 47 +++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index cf9b8cb62..361e64c5a 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -975,6 +975,41 @@ fn delete_refresh_token(connection_id: &str) -> Result<(), String> { } } +/// Delete legacy credentials after canonical authorization without making a failed cleanup secret +/// invisible. On failure the canonical replacement and every original stale identity are persisted +/// together so a later authorization or disconnect has durable identifiers to retry cleanup. +fn cleanup_stale_authorization_credentials( + connection_document_path: &Path, + root: &CloudRoot, + original: &[OAuthConnection], + connection: &OAuthConnection, + mut delete_token: F, +) -> Result<(), String> +where + F: FnMut(&str) -> Result<(), String>, +{ + let stale_ids: Vec<_> = original + .iter() + .filter(|entry| { + entry.connection_id != connection.connection_id && connection_matches_root(entry, root) + }) + .map(|entry| entry.connection_id.clone()) + .collect(); + for stale_id in stale_ids { + if let Err(error) = delete_token(&stale_id) { + let mut retry_visible = original.to_vec(); + retry_visible.retain(|entry| entry.connection_id != connection.connection_id); + retry_visible.push(connection.clone()); + retry_visible.sort_by(|left, right| left.connection_id.cmp(&right.connection_id)); + if save_connections(connection_document_path, &retry_visible).is_err() { + return Err("provider-oauth-keyring-delete-and-config-recovery-failed".into()); + } + return Err(error); + } + } + Ok(()) +} + #[cfg(not(coverage))] pub fn finish_authorization( pending: PendingOAuth, @@ -1013,11 +1048,13 @@ pub fn finish_authorization( } return Err(error); } - for stale in original.iter().filter(|entry| { - entry.connection_id != connection.connection_id && connection_matches_root(entry, root) - }) { - let _ = delete_refresh_token(&stale.connection_id); - } + cleanup_stale_authorization_credentials( + connection_document_path, + root, + &original, + &connection, + delete_refresh_token, + )?; Ok(connection) } From 347ea5f32fbd418e93aba243aae53ce346939519 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:42:40 +0900 Subject: [PATCH 048/157] test(oauth): cover reauthorization cleanup recovery edges --- ..._oauth_reauthorization_cleanup_coverage.rs | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index caa149c23..430198bb9 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -35,7 +35,11 @@ fn unicode_google_root(decomposed: bool) -> CloudRoot { } } -fn google_connection(root: &CloudRoot, connection_id: String, connected_at_ms: u64) -> OAuthConnection { +fn google_connection( + root: &CloudRoot, + connection_id: String, + connected_at_ms: u64, +) -> OAuthConnection { OAuthConnection { connection_id, provider: root.provider, @@ -112,3 +116,56 @@ fn successful_legacy_cleanup_keeps_the_published_document_canonical_only() { assert_eq!(deleted, vec![legacy.connection_id]); assert_eq!(load_connections(&document).unwrap(), vec![canonical]); } + +#[test] +fn no_stale_identity_never_calls_the_credential_delete_boundary() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let requested_root = unicode_google_root(false); + let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); + let original = vec![canonical.clone()]; + save_connections(&document, std::slice::from_ref(&canonical)).unwrap(); + + cleanup_stale_authorization_credentials( + &document, + &requested_root, + &original, + &canonical, + |_| -> Result<(), String> { panic!("no stale credential may reach the delete boundary") }, + ) + .unwrap(); + + assert_eq!(load_connections(&document).unwrap(), vec![canonical]); +} + +#[test] +fn failed_retry_visibility_publication_is_reported_separately() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); + let original = vec![legacy]; + save_connections(&document, std::slice::from_ref(&canonical)).unwrap(); + + std::fs::remove_file(&document).unwrap(); + std::fs::create_dir(&document).unwrap(); + let error = cleanup_stale_authorization_credentials( + &document, + &requested_root, + &original, + &canonical, + |_| Err("provider-oauth-keyring-delete-failed".to_string()), + ) + .unwrap_err(); + + assert_eq!( + error, + "provider-oauth-keyring-delete-and-config-recovery-failed" + ); + assert!( + document.is_dir(), + "recovery publication must fail closed rather than mutate a non-regular authority path" + ); +} From f690f839882f4e87f5cb3b08b49adde83dba08a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:45:31 +0900 Subject: [PATCH 049/157] test(oauth): reserve recovery slot for legacy migration --- ..._oauth_reauthorization_cleanup_coverage.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index 430198bb9..a7431f26e 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -35,6 +35,22 @@ fn unicode_google_root(decomposed: bool) -> CloudRoot { } } +fn unrelated_google_root(index: usize) -> CloudRoot { + #[cfg(windows)] + let path = format!(r"C:\Cloud\unrelated-{index}"); + #[cfg(not(windows))] + let path = format!("/Cloud/unrelated-{index}"); + CloudRoot { + id: path.clone(), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: format!("Google Drive {index}"), + path, + readable: true, + access_issue: None, + } +} + fn google_connection( root: &CloudRoot, connection_id: String, @@ -169,3 +185,47 @@ fn failed_retry_visibility_publication_is_reported_separately() { "recovery publication must fail closed rather than mutate a non-regular authority path" ); } + +#[test] +fn legacy_only_migration_reserves_capacity_for_retry_visible_recovery() { + let saved_root = unicode_google_root(true); + let requested_root = unicode_google_root(false); + let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); + let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); + let mut full_without_canonical = vec![legacy]; + for index in 0..(MAX_CONNECTIONS - 1) { + let unrelated = unrelated_google_root(index); + full_without_canonical.push(google_connection( + &unrelated, + connection_id(&unrelated), + 1_000 + index as u64, + )); + } + assert_eq!(full_without_canonical.len(), MAX_CONNECTIONS); + + assert_eq!( + ensure_reauthorization_cleanup_capacity( + &full_without_canonical, + &requested_root, + &canonical, + ) + .unwrap_err(), + "provider-oauth-reauthorization-recovery-capacity-exhausted" + ); + + let mut with_room = full_without_canonical.clone(); + with_room.pop(); + assert!( + ensure_reauthorization_cleanup_capacity(&with_room, &requested_root, &canonical).is_ok() + ); + + let mut full_with_canonical = with_room; + full_with_canonical.push(canonical.clone()); + assert_eq!(full_with_canonical.len(), MAX_CONNECTIONS); + assert!(ensure_reauthorization_cleanup_capacity( + &full_with_canonical, + &requested_root, + &canonical, + ) + .is_ok()); +} From 0b15d64b925a9a9de9ce7328451acf5313ab3fdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 05:48:28 +0900 Subject: [PATCH 050/157] fix(oauth): reserve retry-visible migration capacity --- src-tauri/src/provider_oauth.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 361e64c5a..610786324 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -975,6 +975,25 @@ fn delete_refresh_token(connection_id: &str) -> Result<(), String> { } } +/// Reserve one durable identity slot when a legacy-only migration may need to keep both the new +/// canonical record and a failed stale credential as a retry handle. +fn ensure_reauthorization_cleanup_capacity( + original: &[OAuthConnection], + root: &CloudRoot, + connection: &OAuthConnection, +) -> Result<(), String> { + let has_stale = original.iter().any(|entry| { + entry.connection_id != connection.connection_id && connection_matches_root(entry, root) + }); + let has_canonical = original + .iter() + .any(|entry| entry.connection_id == connection.connection_id); + if has_stale && !has_canonical && original.len() >= MAX_CONNECTIONS { + return Err("provider-oauth-reauthorization-recovery-capacity-exhausted".into()); + } + Ok(()) +} + /// Delete legacy credentials after canonical authorization without making a failed cleanup secret /// invisible. On failure the canonical replacement and every original stale identity are persisted /// together so a later authorization or disconnect has durable identifiers to retry cleanup. @@ -1037,6 +1056,7 @@ pub fn finish_authorization( }; validate_connection(&connection)?; let original = load_connections(connection_document_path)?; + ensure_reauthorization_cleanup_capacity(&original, root, &connection)?; let mut updated = original.clone(); updated.retain(|entry| !connection_matches_root(entry, root)); updated.push(connection.clone()); From 624e26d16eb76ea8fe564fd0553ddcf758e26ba1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:01:07 +0900 Subject: [PATCH 051/157] test(oauth): inherit ancestor authority regression --- ...vider_oauth_ancestor_authority_coverage.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_ancestor_authority_coverage.rs diff --git a/src-tauri/tests/provider_oauth_ancestor_authority_coverage.rs b/src-tauri/tests/provider_oauth_ancestor_authority_coverage.rs new file mode 100644 index 000000000..6f764c3a9 --- /dev/null +++ b/src-tauri/tests/provider_oauth_ancestor_authority_coverage.rs @@ -0,0 +1,36 @@ +//! Unix authority-chain regression for durable provider OAuth connection metadata. +//! +//! A private immediate app-data directory is not sufficient authority when a non-sticky +//! group/other-writable ancestor can replace that directory entry. This test exercises the real +//! public `load_connections` filesystem boundary without browser, network, keyring, or provider +//! mutation. + +#![cfg(unix)] + +use disksage_lib::provider_oauth::load_connections; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn non_sticky_shared_writable_ancestor_never_authorizes_connection_metadata() { + let temp = tempfile::tempdir().unwrap(); + let shared_ancestor = temp.path().join("shared-ancestor"); + let private_parent = shared_ancestor.join("app-data"); + std::fs::create_dir_all(&private_parent).unwrap(); + + std::fs::set_permissions( + &shared_ancestor, + std::fs::Permissions::from_mode(0o770), + ) + .unwrap(); + std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let document = private_parent.join("cloud-oauth-connections.json"); + std::fs::write(&document, b"{\"version\":1,\"connections\":[]}").unwrap(); + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o600)).unwrap(); + + assert_eq!( + load_connections(&document).unwrap_err(), + "oauth-connection-directory-writable-by-others", + "a replaceable private child must not inherit authority through a non-sticky shared-writable ancestor" + ); +} From bab02a22253412ad7c4c1f36819efd7e18febdbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 06:03:26 +0900 Subject: [PATCH 052/157] test(oauth): inherit durable connection capacity boundary --- ...ider_oauth_connection_capacity_coverage.rs | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_capacity_coverage.rs diff --git a/src-tauri/tests/provider_oauth_connection_capacity_coverage.rs b/src-tauri/tests/provider_oauth_connection_capacity_coverage.rs new file mode 100644 index 000000000..6937c2b6c --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_capacity_coverage.rs @@ -0,0 +1,119 @@ +//! Public-boundary capacity coverage for durable provider OAuth connection metadata. +//! +//! The persisted document deliberately allows at most 32 connections. This regression exercises +//! the exact accepted boundary with distinct, valid records and proves that the 33rd record is +//! rejected before any record can gain lookup authority. It performs only local file I/O; it does +//! not open a browser, contact a provider, access the credential store, or mutate cloud state. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{connection_for_root, load_connections, requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use unicode_normalization::UnicodeNormalization; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; +const MAX_CONNECTIONS: usize = 32; + +fn root(index: usize) -> CloudRoot { + #[cfg(windows)] + let path = format!(r"C:\Cloud\Drive-{index:02}"); + #[cfg(not(windows))] + let path = format!("/Cloud/Drive-{index:02}"); + + CloudRoot { + id: format!("google-drive:account-{index:02}"), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: format!("Google Drive {index:02}"), + path, + readable: true, + access_issue: None, + } +} + +fn connection_id(root: &CloudRoot) -> String { + let root_id = root.id.nfc().collect::(); + let root_path = root.path.nfc().collect::(); + let mut hasher = Sha256::new(); + for value in [root.provider.as_str(), root_id.as_str(), root_path.as_str()] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn connection(root: &CloudRoot, connected_at_ms: u64) -> OAuthConnection { + OAuthConnection { + connection_id: connection_id(root), + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(root.provider) + .expect("Google Drive has a fixed read-only OAuth scope") + .into(), + connected_at_ms, + } +} + +fn write_private(path: &std::path::Path, bytes: &[u8]) { + std::fs::write(path, bytes).expect("write connection document fixture"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("make OAuth metadata fixture owner-private"); + } +} + +#[test] +fn exact_connection_capacity_remains_usable_and_the_next_record_fails_closed() { + let temp = tempfile::tempdir().expect("create isolated app-data directory"); + let path = temp.path().join("cloud-oauth-connections.json"); + + let roots: Vec<_> = (0..MAX_CONNECTIONS).map(root).collect(); + let connections: Vec<_> = roots + .iter() + .enumerate() + .map(|(index, root)| connection(root, 1_000 + index as u64)) + .collect(); + write_private( + &path, + &serde_json::to_vec(&serde_json::json!({ + "version": 1, + "connections": connections, + })) + .expect("serialize exact-capacity fixture"), + ); + + let loaded = load_connections(&path).expect("the documented 32-record capacity must be usable"); + assert_eq!(loaded.len(), MAX_CONNECTIONS); + for (index, root) in roots.iter().enumerate() { + let selected = connection_for_root(&loaded, root) + .expect("every admitted record must remain addressable by its exact root"); + assert_eq!(selected.connection_id, connection_id(root)); + assert_eq!(selected.connected_at_ms, 1_000 + index as u64); + } + + let overflow_root = root(MAX_CONNECTIONS); + let mut overflow = loaded; + overflow.push(connection(&overflow_root, 2_000)); + write_private( + &path, + &serde_json::to_vec(&serde_json::json!({ + "version": 1, + "connections": overflow, + })) + .expect("serialize over-capacity fixture"), + ); + + assert_eq!( + load_connections(&path).expect_err("the 33rd record must never gain lookup authority"), + "oauth-connection-document-version-or-count-invalid" + ); +} From 727c752bdc4900aec20426e9b09c6fbd961bb6e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:01:26 +0900 Subject: [PATCH 053/157] test(oauth): inherit persisted identity field matrix --- ..._oauth_connection_field_matrix_coverage.rs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_field_matrix_coverage.rs diff --git a/src-tauri/tests/provider_oauth_connection_field_matrix_coverage.rs b/src-tauri/tests/provider_oauth_connection_field_matrix_coverage.rs new file mode 100644 index 000000000..4882e5aa6 --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_field_matrix_coverage.rs @@ -0,0 +1,133 @@ +//! Public-boundary coverage for OAuth connection root matching and persisted client-ID admission. +//! +//! These regressions cover authority branches not exercised by the broader connection-document +//! matrix: whitespace-tainted provider credentials propagated through persistence validation, +//! same-provider roots whose filesystem path no longer matches, and bare relative document names +//! whose authority parent is the current directory. They never launch a browser, contact a +//! provider, access the credential store, or mutate a cloud provider. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{ + connection_for_root, load_connections, requested_scope, OAuthConnection, +}; +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn google_root() -> CloudRoot { + #[cfg(windows)] + let path = r"C:\Cloud\Google Drive"; + #[cfg(not(windows))] + let path = "/Cloud/Google Drive"; + + CloudRoot { + id: "google-drive:field-matrix".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: "Google Drive".into(), + path: path.into(), + readable: true, + access_issue: None, + } +} + +fn canonical_connection_id(root: &CloudRoot) -> String { + let root_id = root.id.nfc().collect::(); + let root_path = root.path.nfc().collect::(); + let mut hasher = Sha256::new(); + for value in [root.provider.as_str(), root_id.as_str(), root_path.as_str()] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + use std::fmt::Write as _; + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn valid_connection(root: &CloudRoot) -> OAuthConnection { + OAuthConnection { + connection_id: canonical_connection_id(root), + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(root.provider).unwrap().into(), + connected_at_ms: 1, + } +} + +fn write_private(path: &std::path::Path, connection: &OAuthConnection) { + std::fs::write( + path, + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "connections": [connection] + })) + .unwrap(), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + +#[test] +fn persisted_whitespace_tainted_client_id_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + let root = google_root(); + let mut connection = valid_connection(&root); + connection.client_id = format!(" {GOOGLE_CLIENT_ID}"); + let path = temp.path().join("connections.json"); + write_private(&path, &connection); + + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-client-id-invalid", + "durable metadata must propagate common client-ID admission before lookup" + ); +} + +#[test] +fn same_provider_root_lookup_requires_the_persisted_filesystem_path() { + let root = google_root(); + let connection = valid_connection(&root); + assert_eq!( + connection_for_root(std::slice::from_ref(&connection), &root).unwrap(), + connection + ); + + let mut moved_root = root; + moved_root.path.push_str("-moved"); + assert_eq!( + connection_for_root(std::slice::from_ref(&connection), &moved_root).unwrap_err(), + "provider-oauth-connection-missing", + "same-provider identity alone must not authorize a different filesystem root" + ); +} + +#[test] +fn missing_bare_document_name_uses_current_directory_authority_without_creation() { + let path = std::path::PathBuf::from(format!( + ".disksage-oauth-missing-{}.json", + std::process::id() + )); + assert!( + !path.exists(), + "coverage fixture name must not collide with a repository file" + ); + assert!( + load_connections(&path).unwrap().is_empty(), + "a missing bare filename must remain a non-authorizing empty document" + ); + assert!( + !path.exists(), + "read-only connection lookup must not create the missing document" + ); +} From 9b65717c4de12345bd5ee7e4130794b8d6e46da7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:01:48 +0900 Subject: [PATCH 054/157] test(oauth): inherit public document admission matrix --- ...oauth_public_document_boundary_coverage.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_public_document_boundary_coverage.rs diff --git a/src-tauri/tests/provider_oauth_public_document_boundary_coverage.rs b/src-tauri/tests/provider_oauth_public_document_boundary_coverage.rs new file mode 100644 index 000000000..4adb7baa4 --- /dev/null +++ b/src-tauri/tests/provider_oauth_public_document_boundary_coverage.rs @@ -0,0 +1,73 @@ +//! Public-boundary coverage for OAuth connection-document admission. +//! +//! These cases exercise only local filesystem metadata and JSON parsing. They never touch the +//! browser, loopback callback, provider network, keyring, or cloud mutation authority. + +use disksage_lib::provider_oauth::{connections_path, load_connections}; +use std::path::Path; + +#[cfg(unix)] +fn make_private(path: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[cfg(not(unix))] +fn make_private(_path: &Path) {} + +fn write_private(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + make_private(path); +} + +#[test] +fn connection_path_and_missing_document_are_deterministic_without_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let path = connections_path(temp.path()); + + assert_eq!(path, temp.path().join("cloud-oauth-connections.json")); + assert_eq!(load_connections(&path).unwrap(), Vec::new()); + assert!(!path.exists(), "read-only lookup must not create the document"); +} + +#[test] +fn non_regular_and_oversized_documents_fail_before_json_interpretation() { + let temp = tempfile::tempdir().unwrap(); + let directory_leaf = temp.path().join("connections-directory"); + std::fs::create_dir(&directory_leaf).unwrap(); + assert_eq!( + load_connections(&directory_leaf).unwrap_err(), + "oauth-connection-document-not-regular-file" + ); + + let oversized = temp.path().join("oversized.json"); + write_private(&oversized, &vec![b' '; 256 * 1024 + 1]); + assert_eq!( + load_connections(&oversized).unwrap_err(), + "oauth-connection-document-too-large" + ); +} + +#[test] +fn structured_document_version_and_schema_are_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("connections.json"); + + write_private(&path, br#"{"version":2,"connections":[]}"#); + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-document-version-or-count-invalid" + ); + + write_private( + &path, + br#"{"version":1,"connections":[],"unexpected":true}"#, + ); + assert_eq!( + load_connections(&path).unwrap_err(), + "oauth-connection-document-invalid" + ); + + write_private(&path, br#"{"version":1,"connections":[]}"#); + assert_eq!(load_connections(&path).unwrap(), Vec::new()); +} From f4cf2337d8d5a3d50b18c762b26c6b9494194f3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:03:41 +0900 Subject: [PATCH 055/157] test(oauth): inherit durable metadata validation edges --- ...uth_connection_validation_edge_coverage.rs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_validation_edge_coverage.rs diff --git a/src-tauri/tests/provider_oauth_connection_validation_edge_coverage.rs b/src-tauri/tests/provider_oauth_connection_validation_edge_coverage.rs new file mode 100644 index 000000000..c60c0b09d --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_validation_edge_coverage.rs @@ -0,0 +1,175 @@ +//! Read-only edge coverage for durable provider OAuth connection admission. +//! +//! These regressions exercise the public connection-document parser and deterministic root lookup +//! against malformed authority metadata. They use only private temporary files and never launch a +//! browser, open a loopback listener, contact a provider, or access the OS credential store. + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{ + connection_for_root, load_connections, requested_scope, OAuthConnection, +}; +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; +const MICROSOFT_CLIENT_ID: &str = "12345678-1234-4abc-8def-1234567890ab"; + +fn root(provider: CloudProvider) -> CloudRoot { + #[cfg(windows)] + let path = r"C:\Cloud\Drive"; + #[cfg(not(windows))] + let path = "/Cloud/Drive"; + + CloudRoot { + id: format!("{}:coverage-account", provider.as_str()), + provider, + account_scope: CloudAccountScope::Unknown, + label: "Coverage cloud root".into(), + path: path.into(), + readable: true, + access_issue: None, + } +} + +fn canonical_connection_id(root: &CloudRoot) -> String { + let root_id = root.id.nfc().collect::(); + let root_path = root.path.nfc().collect::(); + let mut hasher = Sha256::new(); + for value in [root.provider.as_str(), root_id.as_str(), root_path.as_str()] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + use std::fmt::Write as _; + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + encoded +} + +fn connection(root: &CloudRoot) -> OAuthConnection { + OAuthConnection { + connection_id: canonical_connection_id(root), + provider: root.provider, + cloud_root_id: root.id.clone(), + cloud_root_path: root.path.clone(), + client_id: match root.provider { + CloudProvider::GoogleDrive => GOOGLE_CLIENT_ID, + CloudProvider::Onedrive => MICROSOFT_CLIENT_ID, + CloudProvider::Icloud => MICROSOFT_CLIENT_ID, + } + .into(), + scope: requested_scope(root.provider).unwrap_or_default().into(), + connected_at_ms: 42, + } +} + +fn write_private(path: &std::path::Path, value: &OAuthConnection) { + let bytes = serde_json::to_vec(&serde_json::json!({ + "version": 1, + "connections": [value] + })) + .unwrap(); + std::fs::write(path, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } +} + +#[test] +fn non_directory_parent_is_rejected_before_document_observation() { + let temp = tempfile::tempdir().unwrap(); + let parent_file = temp.path().join("not-a-directory"); + std::fs::write(&parent_file, b"outside authority").unwrap(); + + assert_eq!( + load_connections(&parent_file.join("connections.json")).unwrap_err(), + "oauth-connection-directory-unsafe" + ); +} + +#[test] +fn malformed_connection_identity_fields_fail_closed_at_the_public_document_boundary() { + let temp = tempfile::tempdir().unwrap(); + let google_root = root(CloudProvider::GoogleDrive); + let valid = connection(&google_root); + + let mut non_hex_id = valid.clone(); + non_hex_id.connection_id = "g".repeat(64); + + let mut mismatched_id = valid.clone(); + mismatched_id.connection_id = "0".repeat(64); + + let mut whitespace_path = valid; + whitespace_path.cloud_root_path = " ".into(); + + for (index, (candidate, expected)) in [ + (non_hex_id, "oauth-connection-invalid"), + (mismatched_id, "oauth-connection-id-mismatch"), + (whitespace_path, "oauth-connection-invalid"), + ] + .into_iter() + .enumerate() + { + let path = temp.path().join(format!("invalid-{index}.json")); + write_private(&path, &candidate); + assert_eq!(load_connections(&path).unwrap_err(), expected); + } +} + +#[test] +fn malformed_connection_fields_are_rejected_before_identity_lookup() { + let temp = tempfile::tempdir().unwrap(); + let google_root = root(CloudProvider::GoogleDrive); + let valid = connection(&google_root); + + let mut whitespace_root_id = valid.clone(); + whitespace_root_id.cloud_root_id = " ".into(); + + let mut relative_root_path = valid.clone(); + relative_root_path.cloud_root_path = "relative/cloud/root".into(); + + let mut wrong_scope = valid.clone(); + wrong_scope.scope = "Files.Read".into(); + + let mut malformed_client = valid; + malformed_client.client_id = "not-a-google-client.apps.googleusercontent.invalid".into(); + + for (index, (candidate, expected)) in [ + (whitespace_root_id, "oauth-connection-invalid"), + (relative_root_path, "oauth-connection-invalid"), + (wrong_scope, "oauth-connection-invalid"), + (malformed_client, "oauth-client-id-provider-format-invalid"), + ] + .into_iter() + .enumerate() + { + let path = temp.path().join(format!("field-invalid-{index}.json")); + write_private(&path, &candidate); + assert_eq!(load_connections(&path).unwrap_err(), expected); + } +} + +#[test] +fn unsupported_provider_connection_and_cross_provider_lookup_do_not_authorize() { + let temp = tempfile::tempdir().unwrap(); + let icloud_root = root(CloudProvider::Icloud); + let icloud_connection = connection(&icloud_root); + let path = temp.path().join("icloud.json"); + write_private(&path, &icloud_connection); + assert_eq!( + load_connections(&path).unwrap_err(), + "icloud-oauth-not-supported" + ); + + let google_root = root(CloudProvider::GoogleDrive); + let google_connection = connection(&google_root); + let onedrive_root = root(CloudProvider::Onedrive); + assert_eq!( + connection_for_root(std::slice::from_ref(&google_connection), &onedrive_root).unwrap_err(), + "provider-oauth-connection-missing" + ); +} From 13ce4d736d3491b1e96c8090e5650de51d39495b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 07:05:41 +0900 Subject: [PATCH 056/157] test(oauth): inherit permission IO edge evidence --- ...vider_oauth_permission_io_edge_coverage.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_permission_io_edge_coverage.rs diff --git a/src-tauri/tests/provider_oauth_permission_io_edge_coverage.rs b/src-tauri/tests/provider_oauth_permission_io_edge_coverage.rs new file mode 100644 index 000000000..dfc6c62c3 --- /dev/null +++ b/src-tauri/tests/provider_oauth_permission_io_edge_coverage.rs @@ -0,0 +1,58 @@ +//! Real-filesystem permission/I/O edge coverage for OAuth connection-document admission. +//! +//! These cases preserve the #156 evidence that is not already covered by the dedicated leaf and +//! shared-writable-parent matrices: an owner-unreadable private leaf must remain an I/O failure, +//! and an untraversable private authority directory must not be mistaken for a missing document. + +#[cfg(unix)] +use disksage_lib::provider_oauth::load_connections; + +#[cfg(unix)] +fn write_private_document(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, b"{\"version\":1,\"connections\":[]}").unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[cfg(unix)] +#[test] +fn owner_unreadable_document_fails_as_io_without_parsing_partial_state() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("connections-owner-unreadable.json"); + write_private_document(&path); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let result = load_connections(&path); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + result.unwrap_err(), + "oauth-connection-document-unreadable", + "an unreadable private leaf must fail closed rather than becoming an empty or parsed document" + ); +} + +#[cfg(unix)] +#[test] +fn untraversable_private_parent_is_unavailable_not_missing() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("oauth-untraversable-parent"); + std::fs::create_dir(&parent).unwrap(); + let path = parent.join("connections.json"); + write_private_document(&path); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o600)).unwrap(); + + let result = load_connections(&path); + + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + result.unwrap_err(), + "oauth-connection-document-unavailable", + "an untraversable authority directory must not be treated as a non-authorizing missing document" + ); +} From 510d8322863b3d9379ece879b5958d3c66a62b58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:09:23 +0900 Subject: [PATCH 057/157] test(oauth): inherit token response admission coverage --- ...er_oauth_token_document_parser_coverage.rs | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_token_document_parser_coverage.rs diff --git a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs new file mode 100644 index 000000000..63dde4115 --- /dev/null +++ b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs @@ -0,0 +1,188 @@ +#![allow(dead_code, unused_imports)] + +//! Credential-free coverage for the OAuth token-response admission boundary. +//! +//! The production parser stays private because provider token documents are not a product API. +//! These regressions include the production module so malformed and boundary responses exercise +//! the shipped parser without contacting a provider, opening a browser, or touching the keyring. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn parse_google(json: &str, refresh_required: bool) -> Result { + parse_token_document( + CloudProvider::GoogleDrive, + GOOGLE_READ_SCOPE, + json, + refresh_required, + ) +} + +fn parse_error(result: Result) -> String { + match result { + Ok(_) => panic!("token document unexpectedly gained OAuth grant authority"), + Err(error) => error, + } +} + +#[test] +fn malformed_or_rejected_token_documents_fail_closed_before_secret_use() { + assert_eq!( + parse_error(parse_google("not-json", false)), + "oauth-token-response-invalid" + ); + assert_eq!( + parse_error(parse_google(r#"{"error":"invalid_grant"}"#, false)), + "oauth-token-endpoint-rejected" + ); + + for json in [ + r#"{"token_type":"Bearer"}"#, + r#"{"access_token":"","token_type":"Bearer"}"#, + r#"{"access_token":"line\nfeed","token_type":"Bearer"}"#, + ] { + assert_eq!( + parse_error(parse_google(json, false)), + "oauth-access-token-invalid", + "missing, empty, or control-bearing access tokens must fail closed" + ); + } + + let oversized_access = "a".repeat(MAX_TOKEN_BYTES + 1); + let json = serde_json::json!({ + "access_token": oversized_access, + "token_type": "Bearer" + }) + .to_string(); + assert_eq!( + parse_error(parse_google(&json, false)), + "oauth-access-token-invalid" + ); +} + +#[test] +fn bearer_type_expiry_and_resource_scope_are_bounded() { + for json in [ + r#"{"access_token":"access"}"#, + r#"{"access_token":"access","token_type":"MAC"}"#, + ] { + assert_eq!( + parse_error(parse_google(json, false)), + "oauth-token-type-invalid" + ); + } + + for expires_in in [0_u64, 86_401] { + let json = serde_json::json!({ + "access_token": "access", + "token_type": "Bearer", + "expires_in": expires_in + }) + .to_string(); + assert_eq!( + parse_error(parse_google(&json, false)), + "oauth-token-expiry-invalid" + ); + } + + let legal_max_expiry = parse_google( + r#"{"access_token":"access","token_type":"bEaReR","expires_in":86400}"#, + false, + ) + .expect("the documented maximum token lifetime and case-insensitive bearer type are valid"); + assert_eq!(legal_max_expiry.access_token.as_str(), "access"); + + assert_eq!( + parse_error(parse_google( + r#"{"access_token":"access","token_type":"Bearer","scope":"https://www.googleapis.com/auth/drive.file"}"#, + false, + )), + "oauth-required-scope-missing" + ); + + let with_extra_scope = parse_google( + r#"{"access_token":"access","token_type":"Bearer","scope":"openid https://www.googleapis.com/auth/drive.metadata.readonly profile"}"#, + false, + ) + .expect("the required Google Drive resource scope may appear among additional grants"); + assert_eq!(with_extra_scope.access_token.as_str(), "access"); + + let omitted_scope = parse_google( + r#"{"access_token":"access","token_type":"Bearer"}"#, + false, + ) + .expect("providers may omit the optional scope echo when the issued access scope is unchanged"); + assert_eq!(omitted_scope.access_token.as_str(), "access"); +} + +#[test] +fn microsoft_scope_echo_represents_access_scope_while_refresh_token_proves_offline_grant() { + let grant = parse_token_document( + CloudProvider::Onedrive, + ONEDRIVE_READ_SCOPE, + r#"{"access_token":"access","refresh_token":"refresh","token_type":"Bearer","scope":"Files.Read"}"#, + true, + ) + .expect("Microsoft access-token scope may omit offline_access while returning a refresh token"); + + assert_eq!(grant.access_token.as_str(), "access"); + assert_eq!(grant.refresh_token.unwrap().as_str(), "refresh"); + + assert_eq!( + parse_error(parse_token_document( + CloudProvider::Onedrive, + ONEDRIVE_READ_SCOPE, + r#"{"access_token":"access","refresh_token":"refresh","token_type":"Bearer","scope":"Files.ReadWrite"}"#, + true, + )), + "oauth-required-scope-missing", + "a token for a different Microsoft Graph resource scope must not satisfy the requested scope" + ); +} + +#[test] +fn refresh_token_requirement_and_value_bounds_fail_closed() { + assert_eq!( + parse_error(parse_google( + r#"{"access_token":"access","token_type":"Bearer"}"#, + true, + )), + "oauth-refresh-token-missing" + ); + + for refresh_token in ["", "refresh\nvalue"] { + let json = serde_json::json!({ + "access_token": "access", + "refresh_token": refresh_token, + "token_type": "Bearer" + }) + .to_string(); + assert_eq!( + parse_error(parse_google(&json, true)), + "oauth-refresh-token-invalid" + ); + } + + let oversized_refresh = "r".repeat(MAX_TOKEN_BYTES + 1); + let json = serde_json::json!({ + "access_token": "access", + "refresh_token": oversized_refresh, + "token_type": "Bearer" + }) + .to_string(); + assert_eq!( + parse_error(parse_google(&json, true)), + "oauth-refresh-token-invalid" + ); + + let valid = parse_google( + r#"{"access_token":"access","refresh_token":"refresh","token_type":"Bearer"}"#, + true, + ) + .expect("a bounded refresh token is required and accepted on initial authorization"); + assert_eq!(valid.access_token.as_str(), "access"); + assert_eq!(valid.refresh_token.unwrap().as_str(), "refresh"); +} From 390e3ccd982a9b7d8964107ca432c9fb917e23a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:09:46 +0900 Subject: [PATCH 058/157] test(oauth): inherit authorization preparation boundary coverage --- ...der_oauth_authorization_public_coverage.rs | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_authorization_public_coverage.rs diff --git a/src-tauri/tests/provider_oauth_authorization_public_coverage.rs b/src-tauri/tests/provider_oauth_authorization_public_coverage.rs new file mode 100644 index 000000000..e93960f93 --- /dev/null +++ b/src-tauri/tests/provider_oauth_authorization_public_coverage.rs @@ -0,0 +1,197 @@ +//! Credential-free public-boundary coverage for provider OAuth authorization preparation. +//! +//! These tests exercise the real pre-browser boundary: loopback listener admission, +//! provider-specific redirect authority, PKCE generation, explicit read/write scope selection, and +//! client-ID validation. They do not launch a browser, contact a provider, touch the keyring, or +//! persist connection metadata. + +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_oauth::{ + prepare_authorization, prepare_authorization_with_write_access, requested_scope, + requested_write_scope, validate_client_id, +}; + +const MICROSOFT_CLIENT_ID: &str = "12345678-1234-4abc-8def-1234567890ab"; +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn query_parameter<'a>(url: &'a str, name: &str) -> &'a str { + let query = url + .split_once('?') + .map(|(_, query)| query) + .expect("authorization URL must contain a query"); + query + .split('&') + .find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then_some(value) + }) + .unwrap_or_else(|| panic!("authorization URL must contain {name}")) +} + +#[test] +fn google_authorization_preparation_is_loopback_pkce_and_read_only_by_default() { + let first = prepare_authorization(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID) + .expect("Google authorization preparation should stay local"); + let second = prepare_authorization(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID) + .expect("a second preparation should bind an independent ephemeral listener"); + + let first_url = first.authorization_url(); + let second_url = second.authorization_url(); + assert!(first_url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); + assert!(first_url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A")); + assert!(first_url.contains( + "scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.metadata.readonly" + )); + assert!(first_url.contains("access_type=offline")); + assert!(first_url.contains("prompt=consent")); + assert!(first_url.contains("include_granted_scopes=true")); + assert!(first_url.contains("code_challenge_method=S256")); + assert!(!first_url.contains("%2Fauth%2Fdrive&")); + assert_eq!(query_parameter(first_url, "state").len(), 43); + assert_eq!(query_parameter(first_url, "code_challenge").len(), 43); + assert_ne!( + query_parameter(first_url, "state"), + query_parameter(second_url, "state"), + "independent authorization preparations must not reuse CSRF state" + ); + assert_ne!( + query_parameter(first_url, "code_challenge"), + query_parameter(second_url, "code_challenge"), + "independent authorization preparations must not reuse PKCE challenges" + ); +} + +#[test] +fn onedrive_authorization_preparation_uses_registered_localhost_and_read_only_scope_by_default() { + let pending = prepare_authorization(CloudProvider::Onedrive, MICROSOFT_CLIENT_ID) + .expect("OneDrive authorization preparation should stay local"); + let url = pending.authorization_url(); + + assert!(url.starts_with( + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?" + )); + assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A")); + assert!(url.contains("scope=Files.Read%20offline_access")); + assert!(url.contains("response_mode=query")); + assert!(url.contains("prompt=select_account")); + assert!(url.contains("code_challenge_method=S256")); + assert!(!url.contains("ReadWrite")); + assert_eq!(query_parameter(url, "state").len(), 43); + assert_eq!(query_parameter(url, "code_challenge").len(), 43); +} + +#[test] +fn write_authorization_requires_an_explicit_caller_choice() { + let google = prepare_authorization_with_write_access( + CloudProvider::GoogleDrive, + GOOGLE_CLIENT_ID, + true, + ) + .expect("explicit Google Drive upload authorization should prepare locally"); + assert!(google + .authorization_url() + .contains("scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive")); + assert!(!google + .authorization_url() + .contains("drive.metadata.readonly")); + + let onedrive = prepare_authorization_with_write_access( + CloudProvider::Onedrive, + MICROSOFT_CLIENT_ID, + true, + ) + .expect("explicit OneDrive upload authorization should prepare locally"); + assert!(onedrive + .authorization_url() + .contains("scope=Files.ReadWrite%20offline_access")); + + assert_eq!( + requested_scope(CloudProvider::GoogleDrive).unwrap(), + "https://www.googleapis.com/auth/drive.metadata.readonly" + ); + assert_eq!( + requested_write_scope(CloudProvider::GoogleDrive).unwrap(), + "https://www.googleapis.com/auth/drive" + ); + assert_eq!( + requested_scope(CloudProvider::Onedrive).unwrap(), + "Files.Read offline_access" + ); + assert_eq!( + requested_write_scope(CloudProvider::Onedrive).unwrap(), + "Files.ReadWrite offline_access" + ); +} + +#[test] +fn unsupported_provider_and_malformed_clients_fail_before_browser_or_network_work() { + assert_eq!( + prepare_authorization(CloudProvider::Icloud, MICROSOFT_CLIENT_ID) + .err() + .expect("iCloud OAuth must remain unsupported"), + "icloud-oauth-not-supported" + ); + + for (provider, client_id) in [ + ( + CloudProvider::GoogleDrive, + "bad_prefix.apps.googleusercontent.com", + ), + (CloudProvider::Onedrive, "not-a-guid"), + ] { + assert_eq!( + prepare_authorization(provider, client_id) + .err() + .expect("malformed provider client IDs must fail closed"), + "oauth-client-id-provider-format-invalid" + ); + } +} + +#[test] +fn client_id_bounds_and_provider_shapes_fail_before_loopback_or_provider_work() { + let oversized = format!("{}{}", "a".repeat(513), ".apps.googleusercontent.com"); + for client_id in [ + "".to_string(), + format!(" {GOOGLE_CLIENT_ID}"), + format!("{GOOGLE_CLIENT_ID} "), + oversized, + "café.apps.googleusercontent.com".to_string(), + "abc\u{0007}xyz.apps.googleusercontent.com".to_string(), + ] { + assert_eq!( + validate_client_id(CloudProvider::GoogleDrive, &client_id), + Err("oauth-client-id-invalid".to_string()) + ); + } + + assert_eq!( + validate_client_id(CloudProvider::Onedrive, MICROSOFT_CLIENT_ID), + Ok(()) + ); + assert_eq!( + validate_client_id(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID), + Ok(()) + ); + for invalid in [ + "1234567-1234-4abc-8def-1234567890ab", + "12345678-1234-4abg-8def-1234567890ab", + "12345678-1234-4abc-8def-1234567890ab-extra", + ] { + assert_eq!( + validate_client_id(CloudProvider::Onedrive, invalid), + Err("oauth-client-id-provider-format-invalid".to_string()) + ); + } + for invalid in [ + ".apps.googleusercontent.com", + "abc_xyz.apps.googleusercontent.com", + "abc/xyz.apps.googleusercontent.com", + "abcxyz.googleusercontent.com", + ] { + assert_eq!( + validate_client_id(CloudProvider::GoogleDrive, invalid), + Err("oauth-client-id-provider-format-invalid".to_string()) + ); + } +} From 87f2cce9ae017d54c90d6459afd305bd744cde9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:10:42 +0900 Subject: [PATCH 059/157] test(oauth): inherit callback parser edge coverage --- ...provider_oauth_callback_parser_coverage.rs | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_callback_parser_coverage.rs diff --git a/src-tauri/tests/provider_oauth_callback_parser_coverage.rs b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs new file mode 100644 index 000000000..96a4215a5 --- /dev/null +++ b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs @@ -0,0 +1,101 @@ +#![allow(dead_code, unused_imports)] + +//! Credential-free coverage for the OAuth callback query parser. +//! +//! The parser is intentionally private. These tests include the production module so percent +//! decoding, CSRF comparison, duplicate-field rejection, denial handling, and authorization-code +//! bounds are exercised without opening a listener, contacting a provider, touching the keyring, +//! or publishing durable OAuth metadata. + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn callback_error(target: &str, expected_state: &str) -> String { + match callback_code(target, expected_state) { + Ok(_) => panic!("invalid callback unexpectedly gained authorization-code authority"), + Err(error) => error, + } +} + +#[test] +fn percent_decoding_and_constant_time_state_comparison_cover_valid_boundaries() { + assert_eq!( + percent_decode("plain+space%2fslash%7E").unwrap(), + "plain space/slash~" + ); + assert_eq!(percent_decode("%E2%82%AC").unwrap(), "€"); + + for invalid in ["%", "%A", "%GG", "%FF"] { + assert_eq!( + percent_decode(invalid).unwrap_err(), + "oauth-callback-query-invalid", + "malformed or non-UTF-8 percent encoding must fail closed" + ); + } + + assert!(constant_time_eq("same-state", "same-state")); + assert!(!constant_time_eq("same-state", "same-State")); + assert!(!constant_time_eq("short", "longer")); + + assert_eq!( + callback_code( + "/?code=abc%2D123&state=state+value&ignored=bounded", + "state value" + ) + .unwrap(), + "abc-123", + "valid form encoding must be decoded before state and code admission" + ); +} + +#[test] +fn malformed_duplicate_denied_and_unbounded_callbacks_fail_closed() { + assert_eq!( + callback_error("/callback?code=one&state=state", "state"), + "oauth-callback-path-invalid" + ); + assert_eq!( + callback_error("/?broken&state=state", "state"), + "oauth-callback-query-invalid" + ); + assert_eq!( + callback_error("/?code=one&code=two&state=state", "state"), + "oauth-callback-query-duplicate" + ); + assert_eq!( + callback_error("/?code=one&state=state&state=state", "state"), + "oauth-callback-query-duplicate" + ); + assert_eq!( + callback_error("/?code=one", "state"), + "oauth-callback-state-missing" + ); + assert_eq!( + callback_error("/?code=one&state=wrong", "state"), + "oauth-callback-state-mismatch" + ); + assert_eq!( + callback_error("/?error=access_denied&state=state", "state"), + "oauth-authorization-denied" + ); + assert_eq!( + callback_error("/?state=state", "state"), + "oauth-callback-code-missing" + ); + + for target in ["/?code=&state=state", "/?code=%0A&state=state"] { + assert_eq!( + callback_error(target, "state"), + "oauth-callback-code-invalid" + ); + } + + let oversized = "a".repeat(MAX_TOKEN_BYTES + 1); + assert_eq!( + callback_error(&format!("/?code={oversized}&state=state"), "state"), + "oauth-callback-code-invalid" + ); +} From f7be4d7499f6840d5534b709dbd3e6031d1d0106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:11:54 +0900 Subject: [PATCH 060/157] test(oauth): require case-sensitive OAuth scope tokens --- ...ider_oauth_token_document_parser_coverage.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs index 63dde4115..0f7af8d18 100644 --- a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs +++ b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs @@ -118,6 +118,23 @@ fn bearer_type_expiry_and_resource_scope_are_bounded() { assert_eq!(omitted_scope.access_token.as_str(), "access"); } +#[test] +fn oauth_scope_tokens_are_case_sensitive() { + let differently_cased = GOOGLE_READ_SCOPE.to_ascii_uppercase(); + let json = serde_json::json!({ + "access_token": "access", + "token_type": "Bearer", + "scope": differently_cased + }) + .to_string(); + + assert_eq!( + parse_error(parse_google(&json, false)), + "oauth-required-scope-missing", + "RFC 6749 scope tokens are case-sensitive and a case-folded token must not gain authority" + ); +} + #[test] fn microsoft_scope_echo_represents_access_scope_while_refresh_token_proves_offline_grant() { let grant = parse_token_document( From d56457291dd9439da2e79daf9999f6f0b8eeb44b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 08:15:13 +0900 Subject: [PATCH 061/157] fix(oauth): compare granted scope tokens exactly --- src-tauri/src/provider_oauth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 610786324..0d57572ef 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -829,7 +829,7 @@ fn parse_token_document( .expect("provider scope is non-empty"); if !scope .split_whitespace() - .any(|granted| granted.eq_ignore_ascii_case(required_resource_scope)) + .any(|granted| granted == required_resource_scope) { return Err("oauth-required-scope-missing".into()); } From aa167f451a99b9e030ae9e76c7c4fb65033f2e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:06:16 +0900 Subject: [PATCH 062/157] test(oauth): require object-bound connection document reads --- ...ection_document_object_binding_contract.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs diff --git a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs new file mode 100644 index 000000000..3a48d66bd --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs @@ -0,0 +1,33 @@ +//! Contract for object-bound OAuth connection-document reads. +//! +//! The durable authority file is attacker-replaceable within the same user session. A path-based +//! metadata check followed by `std::fs::read(path)` re-resolves the pathname and can therefore read +//! a different object (including a just-substituted symlink) than the object that passed admission. +//! The reader must open the final component without following links, validate metadata from that +//! handle, and bound bytes from the same handle. + +#[test] +fn production_reader_is_bound_to_one_open_file_object() { + let source = include_str!("../src/provider_oauth.rs"); + + assert!( + !source.contains("let bytes = std::fs::read(path)"), + "connection-document admission and bytes must not be split across two pathname resolutions" + ); + assert!( + source.contains("libc::O_NOFOLLOW"), + "Unix connection-document open must reject a substituted final-component symlink" + ); + assert!( + source.contains("FILE_FLAG_OPEN_REPARSE_POINT"), + "Windows connection-document open must inspect the reparse-point object instead of following it" + ); + assert!( + source.contains("file.metadata()"), + "regular-file, permission, and size admission must come from the opened object" + ); + assert!( + source.contains(".take(MAX_CONNECTION_DOCUMENT_BYTES + 1)"), + "connection-document reads must remain bounded even if the opened file grows after metadata admission" + ); +} From 5a0f36427bbd3ac6e4126a95cb823184cde3527e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:06:28 +0900 Subject: [PATCH 063/157] chore(oauth): keep object-binding contract with domain owner --- ...ection_document_object_binding_contract.rs | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs diff --git a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs deleted file mode 100644 index 3a48d66bd..000000000 --- a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Contract for object-bound OAuth connection-document reads. -//! -//! The durable authority file is attacker-replaceable within the same user session. A path-based -//! metadata check followed by `std::fs::read(path)` re-resolves the pathname and can therefore read -//! a different object (including a just-substituted symlink) than the object that passed admission. -//! The reader must open the final component without following links, validate metadata from that -//! handle, and bound bytes from the same handle. - -#[test] -fn production_reader_is_bound_to_one_open_file_object() { - let source = include_str!("../src/provider_oauth.rs"); - - assert!( - !source.contains("let bytes = std::fs::read(path)"), - "connection-document admission and bytes must not be split across two pathname resolutions" - ); - assert!( - source.contains("libc::O_NOFOLLOW"), - "Unix connection-document open must reject a substituted final-component symlink" - ); - assert!( - source.contains("FILE_FLAG_OPEN_REPARSE_POINT"), - "Windows connection-document open must inspect the reparse-point object instead of following it" - ); - assert!( - source.contains("file.metadata()"), - "regular-file, permission, and size admission must come from the opened object" - ); - assert!( - source.contains(".take(MAX_CONNECTION_DOCUMENT_BYTES + 1)"), - "connection-document reads must remain bounded even if the opened file grows after metadata admission" - ); -} From 5793cb6d86fc1adac7624a4397196176cb09c2cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:08:31 +0900 Subject: [PATCH 064/157] test(oauth): require object-bound connection document reads --- ...ection_document_object_binding_contract.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs diff --git a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs new file mode 100644 index 000000000..9211f44ee --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs @@ -0,0 +1,33 @@ +//! Contract for object-bound OAuth connection-document reads. +//! +//! The durable authority file is attacker-replaceable within the same user session. A path-based +//! metadata check followed by `std::fs::read(path)` re-resolves the pathname and can therefore read +//! a different object than the object that passed admission. The reader must open the final +//! component without following links, validate metadata from that handle, and bound bytes from the +//! same handle. + +#[test] +fn production_reader_is_bound_to_one_open_file_object() { + let source = include_str!("../src/provider_oauth.rs"); + + assert!( + !source.contains("let bytes = std::fs::read(path)"), + "connection-document admission and bytes must not be split across two pathname resolutions" + ); + assert!( + source.contains("libc::O_NOFOLLOW"), + "Unix connection-document open must reject a substituted final-component symlink" + ); + assert!( + source.contains("FILE_FLAG_OPEN_REPARSE_POINT"), + "Windows connection-document open must inspect the reparse-point object instead of following it" + ); + assert!( + source.contains("file.metadata()"), + "regular-file, permission, and size admission must come from the opened object" + ); + assert!( + source.contains(".take(MAX_CONNECTION_DOCUMENT_BYTES + 1)"), + "connection-document reads must remain bounded even if the opened file grows after metadata admission" + ); +} From c43b4541e603d4b8f0aecbd4939293f5467699b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:14:37 +0900 Subject: [PATCH 065/157] fix(oauth): bind connection reads to opened file object --- src-tauri/src/provider_oauth.rs | 62 +++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 0d57572ef..b889a495f 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -8,12 +8,13 @@ use crate::cloud::{cloud_root_path_matches, CloudProvider, CloudRoot}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::io::Read; use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; #[cfg(not(coverage))] -use std::io::{Read, Write}; +use std::io::Write; #[cfg(not(coverage))] use std::net::{TcpListener, TcpStream}; #[cfg(not(coverage))] @@ -26,6 +27,13 @@ const MAX_CLIENT_ID_BYTES: usize = 512; const MAX_TOKEN_BYTES: usize = 64 * 1024; const KEYRING_SERVICE: &str = "org.contextualwisdomlab.disksage.cloud-oauth"; +#[cfg(windows)] +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; +#[cfg(windows)] +const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; +#[cfg(windows)] +const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + #[cfg(not(coverage))] const MAX_CALLBACK_REQUEST_BYTES: usize = 16 * 1024; #[cfg(not(coverage))] @@ -376,14 +384,47 @@ fn validate_connection_document_parent(parent: &Path, allow_missing: bool) -> Re Ok(()) } +fn open_connection_document(path: &Path) -> Result, String> { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS); + } + match options.open(path) { + Ok(file) => Ok(Some(file)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + #[cfg(unix)] + Err(error) if error.raw_os_error() == Some(libc::ELOOP) => { + Err("oauth-connection-document-not-regular-file".into()) + } + Err(_) => Err("oauth-connection-document-unavailable".into()), + } +} + pub fn load_connections(path: &Path) -> Result, String> { validate_connection_document_parent(connection_document_parent(path), true)?; - let metadata = match std::fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(_) => return Err("oauth-connection-document-unavailable".into()), + let file = match open_connection_document(path)? { + Some(file) => file, + None => return Ok(Vec::new()), }; - if metadata.file_type().is_symlink() || !metadata.is_file() { + let metadata = file + .metadata() + .map_err(|_| "oauth-connection-document-unavailable")?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err("oauth-connection-document-not-regular-file".into()); + } + } + if !metadata.is_file() { return Err("oauth-connection-document-not-regular-file".into()); } #[cfg(unix)] @@ -396,7 +437,14 @@ pub fn load_connections(path: &Path) -> Result, String> { if metadata.len() > MAX_CONNECTION_DOCUMENT_BYTES { return Err("oauth-connection-document-too-large".into()); } - let bytes = std::fs::read(path).map_err(|_| "oauth-connection-document-unreadable")?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + let mut reader = file.take(MAX_CONNECTION_DOCUMENT_BYTES + 1); + reader + .read_to_end(&mut bytes) + .map_err(|_| "oauth-connection-document-unreadable")?; + if bytes.len() as u64 > MAX_CONNECTION_DOCUMENT_BYTES { + return Err("oauth-connection-document-too-large".into()); + } let document: ConnectionDocument = serde_json::from_slice(&bytes).map_err(|_| "oauth-connection-document-invalid")?; if document.version != CONNECTION_DOCUMENT_VERSION From ef7a03725c0a22c260afe9cf1de4b30d2fb51024 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:22:55 +0900 Subject: [PATCH 066/157] test(oauth-cli): inherit selected-root fail-closed process evidence --- .../provider_oauth_selected_root_process.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_selected_root_process.rs diff --git a/src-tauri/tests/provider_oauth_selected_root_process.rs b/src-tauri/tests/provider_oauth_selected_root_process.rs new file mode 100644 index 000000000..47a0c8e3d --- /dev/null +++ b/src-tauri/tests/provider_oauth_selected_root_process.rs @@ -0,0 +1,75 @@ +#![cfg(feature = "cloud-cli")] + +//! Black-box inheritance of the still-valid selected-root safety evidence from #156. +//! +//! These cases stop before browser, credential-store, or provider-network work. They exercise the +//! shipped CLI after local cloud-root discovery and prove that invalid or unconnected roots fail +//! without creating the durable OAuth connection document. + +use std::process::Command; + +fn run_provider_oauth(home: &std::path::Path, args: &[&std::ffi::OsStr]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")); + command.env("HOME", home); + #[cfg(windows)] + command.env("USERPROFILE", home); + command + .args(args) + .output() + .expect("provider OAuth CLI should start") +} + +#[test] +fn discovered_root_reaches_action_guards_without_external_work_or_durable_mutation() { + let temp = tempfile::tempdir().expect("temporary home should be created"); + let root = temp.path().join("OneDrive"); + std::fs::create_dir(&root).expect("OneDrive discovery fixture should be created"); + let connections = temp.path().join("private/connections.json"); + + let connect = run_provider_oauth( + temp.path(), + &[ + "--connect".as_ref(), + "--cloud-root".as_ref(), + root.as_os_str(), + "--client-id".as_ref(), + " ".as_ref(), + "--manual-browser".as_ref(), + "--connections".as_ref(), + connections.as_os_str(), + ], + ); + assert!(!connect.status.success()); + assert!(connect.stdout.is_empty()); + assert_eq!( + String::from_utf8(connect.stderr).expect("diagnostic should be UTF-8").trim(), + "oauth-client-id-invalid" + ); + + for action in ["--verify-capacity", "--disconnect"] { + let output = run_provider_oauth( + temp.path(), + &[ + action.as_ref(), + "--cloud-root".as_ref(), + root.as_os_str(), + "--connections".as_ref(), + connections.as_os_str(), + ], + ); + assert!(!output.status.success(), "action: {action}"); + assert!(output.stdout.is_empty(), "action: {action}"); + assert_eq!( + String::from_utf8(output.stderr) + .expect("diagnostic should be UTF-8") + .trim(), + "provider-oauth-connection-missing", + "action: {action}" + ); + } + + assert!( + !connections.exists(), + "pre-network failure paths must not create a connection document" + ); +} From b47f08f52d7fee4e693739ede297669990446b56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:31:58 +0900 Subject: [PATCH 067/157] test(oauth): reproduce nonblocking callback stream race --- ...der_oauth_loopback_stream_mode_coverage.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs diff --git a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs new file mode 100644 index 000000000..9ec3510d0 --- /dev/null +++ b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs @@ -0,0 +1,44 @@ +//! Real-loopback regression for callback streams accepted from a nonblocking listener. +//! +//! The listener is intentionally nonblocking while authorization is pending. The callback reader +//! must not depend on OS-specific inheritance of that mode: it owns a bounded blocking read with a +//! read timeout after accept. This fixture forces the accepted stream into nonblocking mode and +//! delays the browser-side request so the regression is observable without provider network I/O, +//! keyring access, or durable OAuth mutation. + +#![cfg(not(coverage))] +#![allow(dead_code, unused_imports)] + +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +#[test] +fn callback_reader_normalizes_nonblocking_accepted_stream_before_waiting_for_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("loopback listener binds"); + let address = listener.local_addr().expect("loopback address resolves"); + + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(address).expect("loopback client connects"); + std::thread::sleep(Duration::from_millis(200)); + stream + .write_all( + b"GET /?code=delayed&state=expected HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ) + .expect("delayed callback request writes"); + stream.flush().expect("delayed callback request flushes"); + }); + + let (mut stream, _) = listener.accept().expect("loopback callback accepts"); + stream + .set_nonblocking(true) + .expect("fixture forces inherited-nonblocking shape"); + + let target = read_callback_target(&mut stream) + .expect("callback reader must own a blocking-with-timeout read boundary"); + assert_eq!(target, "/?code=delayed&state=expected"); + + client.join().expect("loopback client joins"); +} From f707e7eb71d5e42a00982e80279a48ff2edbfcd3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:35:00 +0900 Subject: [PATCH 068/157] fix(oauth): normalize callback streams before bounded reads --- src-tauri/src/provider_oauth.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index b889a495f..32cd6129c 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -718,6 +718,9 @@ fn callback_code(target: &str, expected_state: &str) -> Result { #[cfg(not(coverage))] fn read_callback_target(stream: &mut TcpStream) -> Result { + stream + .set_nonblocking(false) + .map_err(|_| "oauth-callback-read-config-failed".to_string())?; stream .set_read_timeout(Some(Duration::from_secs(2))) .map_err(|_| "oauth-callback-read-config-failed".to_string())?; From e774044a6023145bdb41de7c4012e8aaff6ead85 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 09:36:35 +0900 Subject: [PATCH 069/157] test(oauth): inherit public fail-before-side-effect boundaries --- ..._oauth_public_failure_boundary_coverage.rs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_public_failure_boundary_coverage.rs diff --git a/src-tauri/tests/provider_oauth_public_failure_boundary_coverage.rs b/src-tauri/tests/provider_oauth_public_failure_boundary_coverage.rs new file mode 100644 index 000000000..0bbd7ffcd --- /dev/null +++ b/src-tauri/tests/provider_oauth_public_failure_boundary_coverage.rs @@ -0,0 +1,68 @@ +//! Credential-free coverage for public provider OAuth failure boundaries. +//! +//! These regressions exercise shipped public API paths that must fail before browser callbacks, +//! provider network I/O, keyring access, or durable mutation when their prerequisite authority is +//! missing or mismatched. + +#![cfg(not(coverage))] + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{ + disconnect, finish_authorization, prepare_authorization, refreshed_access_token, +}; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn root(provider: CloudProvider) -> CloudRoot { + #[cfg(windows)] + let path = r"C:\Cloud\Coverage"; + #[cfg(not(windows))] + let path = "/Cloud/Coverage"; + + CloudRoot { + id: format!("{}:coverage-account", provider.as_str()), + provider, + account_scope: CloudAccountScope::Unknown, + label: "Coverage cloud root".into(), + path: path.into(), + readable: true, + access_issue: None, + } +} + +#[test] +fn finish_authorization_rejects_provider_root_mismatch_before_callback_or_network_work() { + let pending = prepare_authorization(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID) + .expect("preparation should bind only local loopback listeners"); + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + + assert_eq!( + finish_authorization(pending, &root(CloudProvider::Onedrive), &document, 1).unwrap_err(), + "provider-oauth-root-mismatch" + ); + assert!( + !document.exists(), + "provider mismatch must fail before durable connection publication" + ); +} + +#[test] +fn missing_connection_blocks_refresh_and_disconnect_before_keyring_or_provider_work() { + let temp = tempfile::tempdir().unwrap(); + let document = temp.path().join("connections.json"); + let google_root = root(CloudProvider::GoogleDrive); + + assert_eq!( + refreshed_access_token(&document, &google_root).unwrap_err(), + "provider-oauth-connection-missing" + ); + assert_eq!( + disconnect(&document, &google_root).unwrap_err(), + "provider-oauth-connection-missing" + ); + assert!( + !document.exists(), + "read-only missing-connection failures must not create durable OAuth state" + ); +} From 4fa21fedee0e720aacf5726dd2e5a6b03e222aed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:03:45 +0900 Subject: [PATCH 070/157] test(oauth): reject malformed loopback Host authority --- ..._oauth_loopback_host_authority_coverage.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_loopback_host_authority_coverage.rs diff --git a/src-tauri/tests/provider_oauth_loopback_host_authority_coverage.rs b/src-tauri/tests/provider_oauth_loopback_host_authority_coverage.rs new file mode 100644 index 000000000..e47b918dd --- /dev/null +++ b/src-tauri/tests/provider_oauth_loopback_host_authority_coverage.rs @@ -0,0 +1,117 @@ +//! Credential-free loopback HTTP authority regression for provider OAuth. +//! +//! A forged or malformed HTTP/1.1 Host field must be rejected as request framing before DiskSage +//! interprets an otherwise valid OAuth denial. Only the exact authority from the generated +//! loopback redirect may terminate the pending authorization. + +#![cfg(not(coverage))] + +use disksage_lib::cloud::{CloudAccountScope, CloudProvider, CloudRoot}; +use disksage_lib::provider_oauth::{connections_path, finish_authorization, prepare_authorization}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn query_value<'a>(url: &'a str, key: &str) -> &'a str { + let query = url + .split_once('?') + .map(|(_, query)| query) + .expect("authorization URL has query parameters"); + query + .split('&') + .find_map(|pair| { + let (candidate_key, value) = pair.split_once('=')?; + (candidate_key == key).then_some(value) + }) + .unwrap_or_else(|| panic!("authorization URL is missing {key}")) +} + +fn google_loopback_port(url: &str) -> u16 { + const PREFIX: &str = "http%3A%2F%2F127.0.0.1%3A"; + query_value(url, "redirect_uri") + .strip_prefix(PREFIX) + .expect("Google redirect URI uses the loopback IP form") + .parse() + .expect("loopback port is numeric") +} + +fn send_request(port: u16, request: String) { + let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("loopback listener accepts"); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .expect("read timeout configured"); + stream + .write_all(request.as_bytes()) + .expect("callback request written"); + stream.flush().expect("callback request flushed"); + + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("bounded loopback response read"); + assert!(response.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(response.contains("Cache-Control: no-store\r\n")); +} + +#[test] +fn malformed_or_foreign_host_cannot_consume_the_pending_oauth_state() { + let temp = tempfile::tempdir().unwrap(); + let connection_path = connections_path(temp.path()); + + #[cfg(windows)] + let root_path = r"C:\Cloud\google-account"; + #[cfg(not(windows))] + let root_path = "/Cloud/google-account"; + + let root = CloudRoot { + id: "google-account".into(), + provider: CloudProvider::GoogleDrive, + account_scope: CloudAccountScope::Unknown, + label: "Google Drive".into(), + path: root_path.into(), + readable: true, + access_issue: None, + }; + + let pending = prepare_authorization(CloudProvider::GoogleDrive, GOOGLE_CLIENT_ID).unwrap(); + let authorization_url = pending.authorization_url().to_owned(); + let port = google_loopback_port(&authorization_url); + let state = query_value(&authorization_url, "state").to_owned(); + + let worker = std::thread::spawn(move || { + finish_authorization(pending, &root, &connection_path, 123) + }); + + let denial_target = format!("/?error=access_denied&state={state}"); + + send_request( + port, + format!( + "GET {denial_target} HTTP/1.1\r\nHost: attacker.invalid\r\nConnection: close\r\n\r\n" + ), + ); + send_request( + port, + format!("GET {denial_target} HTTP/1.1\r\nConnection: close\r\n\r\n"), + ); + send_request( + port, + format!( + "GET {denial_target} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ), + ); + send_request( + port, + format!( + "GET {denial_target} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ), + ); + + assert_eq!( + worker.join().expect("authorization worker joins").unwrap_err(), + "oauth-authorization-denied" + ); + assert!(!connections_path(temp.path()).exists()); +} From 891d53803c48e022ccdf5a75fb536f66bdbc0ed0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:09:09 +0900 Subject: [PATCH 071/157] fix(oauth): bind loopback callback Host authority --- src-tauri/src/provider_oauth.rs | 49 +++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 32cd6129c..350ede647 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -717,7 +717,7 @@ fn callback_code(target: &str, expected_state: &str) -> Result { } #[cfg(not(coverage))] -fn read_callback_target(stream: &mut TcpStream) -> Result { +fn read_callback_target(stream: &mut TcpStream, expected_host: &str) -> Result { stream .set_nonblocking(false) .map_err(|_| "oauth-callback-read-config-failed".to_string())?; @@ -745,8 +745,8 @@ fn read_callback_target(stream: &mut TcpStream) -> Result { return Err("oauth-callback-request-invalid".into()); } let request = std::str::from_utf8(&request).map_err(|_| "oauth-callback-request-invalid")?; - let first_line = request - .split("\r\n") + let mut lines = request.split("\r\n"); + let first_line = lines .next() .ok_or_else(|| "oauth-callback-request-invalid".to_string())?; let mut fields = first_line.split_whitespace(); @@ -759,6 +759,31 @@ fn read_callback_target(stream: &mut TcpStream) -> Result { if fields.next() != Some("HTTP/1.1") || fields.next().is_some() { return Err("oauth-callback-request-invalid".into()); } + let mut host = None; + for line in lines { + if line.is_empty() { + break; + } + let (name, raw_value) = line + .split_once(':') + .ok_or_else(|| "oauth-callback-request-invalid".to_string())?; + if name.is_empty() || name.trim() != name { + return Err("oauth-callback-request-invalid".into()); + } + if name.eq_ignore_ascii_case("host") { + if host.is_some() { + return Err("oauth-callback-host-invalid".into()); + } + let value = raw_value.trim_matches(|character| character == ' ' || character == '\t'); + if value.is_empty() { + return Err("oauth-callback-host-invalid".into()); + } + host = Some(value); + } + } + if !host.is_some_and(|value| value.eq_ignore_ascii_case(expected_host)) { + return Err("oauth-callback-host-invalid".into()); + } Ok(target.to_owned()) } @@ -789,7 +814,11 @@ fn send_callback_response(stream: &mut TcpStream, accepted: bool) { } #[cfg(not(coverage))] -fn wait_for_callback(listeners: &[TcpListener], expected_state: &str) -> Result { +fn wait_for_callback( + listeners: &[TcpListener], + expected_state: &str, + expected_host: &str, +) -> Result { let deadline = Instant::now() + CALLBACK_TIMEOUT; while Instant::now() < deadline { for listener in listeners { @@ -799,7 +828,7 @@ fn wait_for_callback(listeners: &[TcpListener], expected_state: &str) -> Result< send_callback_response(&mut stream, false); continue; } - let result = read_callback_target(&mut stream) + let result = read_callback_target(&mut stream, expected_host) .and_then(|target| callback_code(&target, expected_state)); match result { Ok(code) => { @@ -1090,7 +1119,15 @@ pub fn finish_authorization( if pending.provider != root.provider { return Err("provider-oauth-root-mismatch".into()); } - let code = Zeroizing::new(wait_for_callback(&pending.listeners, &pending.state)?); + let expected_host = pending + .redirect_uri + .strip_prefix("http://") + .ok_or_else(|| "oauth-redirect-uri-invalid".to_string())?; + let code = Zeroizing::new(wait_for_callback( + &pending.listeners, + &pending.state, + expected_host, + )?); let grant = exchange_authorization_code(&pending, code.as_str())?; let refresh_token = grant .refresh_token From d7cf6b7dba74bf3e58c7e033c00716ad3add1bb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:09:39 +0900 Subject: [PATCH 072/157] test(oauth): adapt stream-mode fixture to Host-bound reader --- src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs index 9ec3510d0..2ba4f58e2 100644 --- a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs +++ b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs @@ -36,7 +36,7 @@ fn callback_reader_normalizes_nonblocking_accepted_stream_before_waiting_for_req .set_nonblocking(true) .expect("fixture forces inherited-nonblocking shape"); - let target = read_callback_target(&mut stream) + let target = read_callback_target(&mut stream, "127.0.0.1") .expect("callback reader must own a blocking-with-timeout read boundary"); assert_eq!(target, "/?code=delayed&state=expected"); From 44847ad7fd557005608e36ea40ee8d7ca6ecfc8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:15:16 +0900 Subject: [PATCH 073/157] test(oauth): preserve native CLI host boundaries --- ...rovider_oauth_cli_host_process_coverage.rs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_cli_host_process_coverage.rs diff --git a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs new file mode 100644 index 000000000..eb0889636 --- /dev/null +++ b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs @@ -0,0 +1,79 @@ +#![cfg(feature = "cloud-cli")] + +//! Black-box host-boundary regressions for the shipped provider OAuth CLI. +//! +//! These cases terminate before browser launch, provider network I/O, credential-store access, or +//! cloud mutation. The Windows case performs only the read-only `--list` action in an isolated +//! temporary profile. + +use std::process::Command; + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) +} + +#[test] +fn sole_help_is_a_successful_stdout_contract() { + let output = command() + .arg("--help") + .output() + .expect("provider OAuth CLI starts"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("help is UTF-8"); + assert!(stdout.starts_with("usage: disksage-provider-oauth ")); + assert!(stdout.contains("[--write-access]")); +} + +#[test] +fn help_mixed_with_domain_arguments_is_a_bounded_failure() { + let output = command() + .args(["--help", "--list"]) + .output() + .expect("provider OAuth CLI starts"); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"help must be used alone\n"); +} + +#[cfg(windows)] +#[test] +fn read_only_list_falls_back_to_userprofile_when_home_is_absent() { + let temp = tempfile::tempdir().expect("isolated Windows profile exists"); + let output = command() + .env_remove("HOME") + .env("USERPROFILE", temp.path()) + .arg("--list") + .output() + .expect("provider OAuth CLI starts"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 0); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + +#[cfg(unix)] +#[test] +fn non_utf8_host_argument_fails_without_panic_or_reflection() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let sensitive = OsString::from_vec(vec![0xff, b'/', b'p', b'r', b'i', b'v', b'a', b't', b'e']); + let output = command() + .arg(sensitive) + .output() + .expect("provider OAuth CLI starts"); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"argument-encoding-invalid\n"); +} From b26348cdb245dde5fd6133aa40ffb8b88c229b55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:16:15 +0900 Subject: [PATCH 074/157] fix(oauth): isolate native CLI host admission --- .../src/bin/disksage-provider-oauth-host.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src-tauri/src/bin/disksage-provider-oauth-host.rs diff --git a/src-tauri/src/bin/disksage-provider-oauth-host.rs b/src-tauri/src/bin/disksage-provider-oauth-host.rs new file mode 100644 index 000000000..7383fe96e --- /dev/null +++ b/src-tauri/src/bin/disksage-provider-oauth-host.rs @@ -0,0 +1,129 @@ +//! Native host adapter for the shipped provider OAuth CLI. +//! +//! The domain CLI implementation remains in `disksage-provider-oauth.rs`. This adapter owns only +//! process-host concerns: lossless argument admission, terminal help semantics, and platform home +//! discovery before delegating to the existing fail-closed parser and executor. + +#[cfg(not(coverage))] +use std::ffi::{OsStr, OsString}; +#[cfg(not(coverage))] +use std::path::PathBuf; + +#[cfg(not(coverage))] +#[path = "../home_resolution.rs"] +mod home_resolution; + +#[cfg(not(coverage))] +mod implementation { + include!("disksage-provider-oauth.rs"); + + pub(super) fn usage_text() -> String { + usage() + } + + pub(super) fn run_with_environment( + args: Vec, + environment_home: Option, + ) -> Result<(), String> { + let parsed = parse_args(&args, environment_home)?; + let output = execute(parsed)?; + println!( + "{}", + serde_json::to_string_pretty(&output) + .map_err(|_| "provider-oauth-output-serialization-failed".to_string())? + ); + Ok(()) + } +} + +#[cfg(not(coverage))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum TerminalArgs { + Help, + Run(Vec), +} + +#[cfg(not(coverage))] +fn is_help(value: &OsStr) -> bool { + value == OsStr::new("--help") || value == OsStr::new("-h") +} + +#[cfg(not(coverage))] +fn parse_terminal_args(args: Vec) -> Result { + if args.iter().any(|value| value.to_str().is_none()) { + return Err("argument-encoding-invalid".into()); + } + match args.as_slice() { + [only] if is_help(only) => return Ok(TerminalArgs::Help), + values if values.iter().any(|value| is_help(value)) => { + return Err("help must be used alone".into()); + } + _ => {} + } + Ok(TerminalArgs::Run( + args.into_iter() + .map(|value| { + value + .into_string() + .expect("non-UTF-8 arguments were rejected before domain parsing") + }) + .collect(), + )) +} + +#[cfg(all(not(coverage), windows))] +fn environment_home() -> Option { + home_resolution::select_absolute_home([ + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("USERPROFILE").map(PathBuf::from), + home_resolution::windows_home_drive_path(), + ]) + .ok() +} + +#[cfg(all(not(coverage), not(windows)))] +fn environment_home() -> Option { + home_resolution::select_absolute_home([std::env::var_os("HOME").map(PathBuf::from)]).ok() +} + +#[cfg(not(coverage))] +fn main() { + let args = std::env::args_os().skip(1).collect::>(); + let result = match parse_terminal_args(args) { + Ok(TerminalArgs::Help) => { + println!("{}", implementation::usage_text()); + Ok(()) + } + Ok(TerminalArgs::Run(args)) => implementation::run_with_environment(args, environment_home()), + Err(error) => Err(error), + }; + if let Err(error) = result { + eprintln!("{error}"); + std::process::exit(1); + } +} + +#[cfg(coverage)] +fn main() {} + +#[cfg(all(test, not(coverage)))] +mod tests { + use super::*; + + #[test] + fn terminal_parser_separates_help_from_domain_arguments() { + assert_eq!( + parse_terminal_args(vec![OsString::from("--help")]).unwrap(), + TerminalArgs::Help + ); + assert_eq!( + parse_terminal_args(vec![OsString::from("--help"), OsString::from("--list")]) + .unwrap_err(), + "help must be used alone" + ); + assert_eq!( + parse_terminal_args(vec![OsString::from("--list")]).unwrap(), + TerminalArgs::Run(vec!["--list".to_string()]) + ); + } +} From 58bc7e457da57934dedfd21188bffe3260ff49e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:16:30 +0900 Subject: [PATCH 075/157] fix(oauth): route CLI through host adapter --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 15cc5b18c..e98734b08 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -125,7 +125,7 @@ path = "src/bin/disksage-podman-reclaim-plan.rs" [[bin]] name = "disksage-provider-oauth" -path = "src/bin/disksage-provider-oauth.rs" +path = "src/bin/disksage-provider-oauth-host.rs" required-features = ["cloud-cli"] [[bin]] From fb3fd004c99cd5b54d3ec3367515621a8f718963 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:17:35 +0900 Subject: [PATCH 076/157] test(oauth): bind host adapter home precedence --- .../src/bin/disksage-provider-oauth-host.rs | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-host.rs b/src-tauri/src/bin/disksage-provider-oauth-host.rs index 7383fe96e..8708d8d3a 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-host.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-host.rs @@ -71,19 +71,39 @@ fn parse_terminal_args(args: Vec) -> Result { )) } +#[cfg(not(coverage))] +fn environment_home_from( + home: Option, + user_profile: Option, + windows_home_drive_path: Option, + windows: bool, +) -> Option { + let candidates = if windows { + vec![home, user_profile, windows_home_drive_path] + } else { + vec![home] + }; + home_resolution::select_absolute_home(candidates).ok() +} + #[cfg(all(not(coverage), windows))] fn environment_home() -> Option { - home_resolution::select_absolute_home([ + environment_home_from( std::env::var_os("HOME").map(PathBuf::from), std::env::var_os("USERPROFILE").map(PathBuf::from), home_resolution::windows_home_drive_path(), - ]) - .ok() + true, + ) } #[cfg(all(not(coverage), not(windows)))] fn environment_home() -> Option { - home_resolution::select_absolute_home([std::env::var_os("HOME").map(PathBuf::from)]).ok() + environment_home_from( + std::env::var_os("HOME").map(PathBuf::from), + None, + None, + false, + ) } #[cfg(not(coverage))] @@ -94,7 +114,9 @@ fn main() { println!("{}", implementation::usage_text()); Ok(()) } - Ok(TerminalArgs::Run(args)) => implementation::run_with_environment(args, environment_home()), + Ok(TerminalArgs::Run(args)) => { + implementation::run_with_environment(args, environment_home()) + } Err(error) => Err(error), }; if let Err(error) = result { @@ -126,4 +148,20 @@ mod tests { TerminalArgs::Run(vec!["--list".to_string()]) ); } + + #[test] + fn windows_home_selection_uses_the_first_absolute_native_candidate() { + let profile = std::env::temp_dir().join("disksage-user-profile"); + let drive_path = std::env::temp_dir().join("disksage-home-drive-path"); + assert_eq!( + environment_home_from(None, Some(profile.clone()), Some(drive_path), true), + Some(profile) + ); + } + + #[test] + fn non_windows_home_selection_does_not_import_windows_fallbacks() { + let profile = std::env::temp_dir().join("disksage-user-profile"); + assert_eq!(environment_home_from(None, Some(profile), None, false), None); + } } From 9826839d15db943a5497b20974bf295e9e004d25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:17:40 +0900 Subject: [PATCH 077/157] test(oauth): execute host adapter contracts in default Rust suite --- .../tests/provider_oauth_cli_host_adapter_coverage.rs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs diff --git a/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs new file mode 100644 index 000000000..332da4910 --- /dev/null +++ b/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs @@ -0,0 +1,7 @@ +//! Compile and execute the provider OAuth host adapter's pure host-boundary contracts in the +//! default Rust test suite, independent of provider credentials or the feature-gated binary run. + +#![allow(dead_code, unused_imports)] + +#[path = "../src/bin/disksage-provider-oauth-host.rs"] +mod provider_oauth_cli_host; From 45e328daa1dfc8ef2823313254e98f1229d2189b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:18:55 +0900 Subject: [PATCH 078/157] fix(oauth): harden native CLI host boundary --- src-tauri/src/bin/disksage-provider-oauth.rs | 113 ++++++++++++++++++- 1 file changed, 110 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/src/bin/disksage-provider-oauth.rs index eb16b49dd..fb1ad690c 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ b/src-tauri/src/bin/disksage-provider-oauth.rs @@ -4,6 +4,8 @@ //! non-secret connection descriptors and provider capacity evidence; this command itself never //! performs a cloud file write or source eviction. +#[cfg(not(coverage))] +use std::ffi::{OsStr, OsString}; #[cfg(not(coverage))] use std::path::{Path, PathBuf}; #[cfg(not(coverage))] @@ -16,6 +18,10 @@ use disksage_lib::provider_capacity::{self, FixedHostProviderCapacityClient}; #[cfg(not(coverage))] use disksage_lib::provider_oauth::{self, OAuthConnection}; +#[cfg(not(coverage))] +#[path = "../home_resolution.rs"] +mod home_resolution; + #[cfg(not(coverage))] const OUTPUT_SCHEMA_VERSION: u32 = 1; #[cfg(not(coverage))] @@ -388,10 +394,87 @@ fn execute(args: Args) -> Result { } } +#[cfg(not(coverage))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum TerminalArgs { + Help, + Run(Vec), +} + +#[cfg(not(coverage))] +fn is_help(value: &OsStr) -> bool { + value == OsStr::new("--help") || value == OsStr::new("-h") +} + +#[cfg(not(coverage))] +fn parse_terminal_args(args: Vec) -> Result { + if args.iter().any(|value| value.to_str().is_none()) { + return Err("argument-encoding-invalid".into()); + } + match args.as_slice() { + [only] if is_help(only) => return Ok(TerminalArgs::Help), + values if values.iter().any(|value| is_help(value)) => { + return Err("help must be used alone".into()); + } + _ => {} + } + Ok(TerminalArgs::Run( + args.into_iter() + .map(|value| { + value + .into_string() + .expect("non-UTF-8 arguments were rejected before domain parsing") + }) + .collect(), + )) +} + +#[cfg(not(coverage))] +fn environment_home_from( + home: Option, + user_profile: Option, + windows_home_drive_path: Option, + windows: bool, +) -> Option { + let candidates = if windows { + vec![home, user_profile, windows_home_drive_path] + } else { + vec![home] + }; + home_resolution::select_absolute_home(candidates).ok() +} + +#[cfg(all(not(coverage), windows))] +fn environment_home() -> Option { + environment_home_from( + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("USERPROFILE").map(PathBuf::from), + home_resolution::windows_home_drive_path(), + true, + ) +} + +#[cfg(all(not(coverage), not(windows)))] +fn environment_home() -> Option { + environment_home_from( + std::env::var_os("HOME").map(PathBuf::from), + None, + None, + false, + ) +} + #[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = std::env::args().skip(1).collect::>(); - let parsed = parse_args(&args, std::env::var_os("HOME").map(PathBuf::from))?; + let terminal_args = parse_terminal_args(std::env::args_os().skip(1).collect())?; + let args = match terminal_args { + TerminalArgs::Help => { + println!("{}", usage()); + return Ok(()); + } + TerminalArgs::Run(args) => args, + }; + let parsed = parse_args(&args, environment_home())?; let output = execute(parsed)?; println!( "{}", @@ -544,7 +627,7 @@ mod tests { home.clone(), ) .is_err()); - assert!(parse_args(&strings(&["--list", "--home", "relative"]), home,).is_err()); + assert!(parse_args(&strings(&["--list", "--home", "relative"]), home).is_err()); } #[test] @@ -601,4 +684,28 @@ mod tests { assert!(encoded.get("access_token").is_none()); assert!(encoded.get("refresh_token").is_none()); } + + #[test] + fn terminal_host_parser_keeps_help_success_separate_from_domain_parsing() { + assert_eq!( + parse_terminal_args(vec![OsString::from("--help")]).unwrap(), + TerminalArgs::Help + ); + assert_eq!( + parse_terminal_args(vec![OsString::from("--help"), OsString::from("--list")]) + .unwrap_err(), + "help must be used alone" + ); + } + + #[test] + fn windows_environment_home_falls_back_to_user_profile_without_importing_it_on_unix() { + let profile = std::env::temp_dir().join("disksage-user-profile"); + let drive_path = std::env::temp_dir().join("disksage-home-drive-path"); + assert_eq!( + environment_home_from(None, Some(profile.clone()), Some(drive_path), true), + Some(profile.clone()) + ); + assert_eq!(environment_home_from(None, Some(profile), None, false), None); + } } From 3444f4b04e121fe830da063feee999e114d84aec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:19:20 +0900 Subject: [PATCH 079/157] fix(oauth): keep canonical CLI entrypoint direct --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e98734b08..15cc5b18c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -125,7 +125,7 @@ path = "src/bin/disksage-podman-reclaim-plan.rs" [[bin]] name = "disksage-provider-oauth" -path = "src/bin/disksage-provider-oauth-host.rs" +path = "src/bin/disksage-provider-oauth.rs" required-features = ["cloud-cli"] [[bin]] From 01b22d23cdb6481589a6022ee559807c4c3e1c25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:19:29 +0900 Subject: [PATCH 080/157] test(oauth): exercise canonical CLI entrypoint in default suite --- .../tests/provider_oauth_cli_host_adapter_coverage.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs index 332da4910..1bbf23626 100644 --- a/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs +++ b/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs @@ -1,7 +1,7 @@ -//! Compile and execute the provider OAuth host adapter's pure host-boundary contracts in the -//! default Rust test suite, independent of provider credentials or the feature-gated binary run. +//! Compile and execute the provider OAuth CLI's pure host-boundary contracts in the default Rust +//! test suite, independent of provider credentials or feature-gated process execution. #![allow(dead_code, unused_imports)] -#[path = "../src/bin/disksage-provider-oauth-host.rs"] -mod provider_oauth_cli_host; +#[path = "../src/bin/disksage-provider-oauth.rs"] +mod provider_oauth_cli; From 66f346ea149951678b96cd8e5a9260f1eb77dc20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:19:38 +0900 Subject: [PATCH 081/157] refactor(oauth): remove superseded host wrapper --- .../src/bin/disksage-provider-oauth-host.rs | 167 ------------------ 1 file changed, 167 deletions(-) delete mode 100644 src-tauri/src/bin/disksage-provider-oauth-host.rs diff --git a/src-tauri/src/bin/disksage-provider-oauth-host.rs b/src-tauri/src/bin/disksage-provider-oauth-host.rs deleted file mode 100644 index 8708d8d3a..000000000 --- a/src-tauri/src/bin/disksage-provider-oauth-host.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Native host adapter for the shipped provider OAuth CLI. -//! -//! The domain CLI implementation remains in `disksage-provider-oauth.rs`. This adapter owns only -//! process-host concerns: lossless argument admission, terminal help semantics, and platform home -//! discovery before delegating to the existing fail-closed parser and executor. - -#[cfg(not(coverage))] -use std::ffi::{OsStr, OsString}; -#[cfg(not(coverage))] -use std::path::PathBuf; - -#[cfg(not(coverage))] -#[path = "../home_resolution.rs"] -mod home_resolution; - -#[cfg(not(coverage))] -mod implementation { - include!("disksage-provider-oauth.rs"); - - pub(super) fn usage_text() -> String { - usage() - } - - pub(super) fn run_with_environment( - args: Vec, - environment_home: Option, - ) -> Result<(), String> { - let parsed = parse_args(&args, environment_home)?; - let output = execute(parsed)?; - println!( - "{}", - serde_json::to_string_pretty(&output) - .map_err(|_| "provider-oauth-output-serialization-failed".to_string())? - ); - Ok(()) - } -} - -#[cfg(not(coverage))] -#[derive(Debug, Clone, PartialEq, Eq)] -enum TerminalArgs { - Help, - Run(Vec), -} - -#[cfg(not(coverage))] -fn is_help(value: &OsStr) -> bool { - value == OsStr::new("--help") || value == OsStr::new("-h") -} - -#[cfg(not(coverage))] -fn parse_terminal_args(args: Vec) -> Result { - if args.iter().any(|value| value.to_str().is_none()) { - return Err("argument-encoding-invalid".into()); - } - match args.as_slice() { - [only] if is_help(only) => return Ok(TerminalArgs::Help), - values if values.iter().any(|value| is_help(value)) => { - return Err("help must be used alone".into()); - } - _ => {} - } - Ok(TerminalArgs::Run( - args.into_iter() - .map(|value| { - value - .into_string() - .expect("non-UTF-8 arguments were rejected before domain parsing") - }) - .collect(), - )) -} - -#[cfg(not(coverage))] -fn environment_home_from( - home: Option, - user_profile: Option, - windows_home_drive_path: Option, - windows: bool, -) -> Option { - let candidates = if windows { - vec![home, user_profile, windows_home_drive_path] - } else { - vec![home] - }; - home_resolution::select_absolute_home(candidates).ok() -} - -#[cfg(all(not(coverage), windows))] -fn environment_home() -> Option { - environment_home_from( - std::env::var_os("HOME").map(PathBuf::from), - std::env::var_os("USERPROFILE").map(PathBuf::from), - home_resolution::windows_home_drive_path(), - true, - ) -} - -#[cfg(all(not(coverage), not(windows)))] -fn environment_home() -> Option { - environment_home_from( - std::env::var_os("HOME").map(PathBuf::from), - None, - None, - false, - ) -} - -#[cfg(not(coverage))] -fn main() { - let args = std::env::args_os().skip(1).collect::>(); - let result = match parse_terminal_args(args) { - Ok(TerminalArgs::Help) => { - println!("{}", implementation::usage_text()); - Ok(()) - } - Ok(TerminalArgs::Run(args)) => { - implementation::run_with_environment(args, environment_home()) - } - Err(error) => Err(error), - }; - if let Err(error) = result { - eprintln!("{error}"); - std::process::exit(1); - } -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(test, not(coverage)))] -mod tests { - use super::*; - - #[test] - fn terminal_parser_separates_help_from_domain_arguments() { - assert_eq!( - parse_terminal_args(vec![OsString::from("--help")]).unwrap(), - TerminalArgs::Help - ); - assert_eq!( - parse_terminal_args(vec![OsString::from("--help"), OsString::from("--list")]) - .unwrap_err(), - "help must be used alone" - ); - assert_eq!( - parse_terminal_args(vec![OsString::from("--list")]).unwrap(), - TerminalArgs::Run(vec!["--list".to_string()]) - ); - } - - #[test] - fn windows_home_selection_uses_the_first_absolute_native_candidate() { - let profile = std::env::temp_dir().join("disksage-user-profile"); - let drive_path = std::env::temp_dir().join("disksage-home-drive-path"); - assert_eq!( - environment_home_from(None, Some(profile.clone()), Some(drive_path), true), - Some(profile) - ); - } - - #[test] - fn non_windows_home_selection_does_not_import_windows_fallbacks() { - let profile = std::env::temp_dir().join("disksage-user-profile"); - assert_eq!(environment_home_from(None, Some(profile), None, false), None); - } -} From 1eed5434c678f1e31097ba75f788c94819048648 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:21:58 +0900 Subject: [PATCH 082/157] test(oauth): inherit CLI connection-document process evidence --- ...rovider_oauth_cli_host_process_coverage.rs | 107 +++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs index eb0889636..df91b3fc2 100644 --- a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs +++ b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs @@ -1,17 +1,76 @@ #![cfg(feature = "cloud-cli")] -//! Black-box host-boundary regressions for the shipped provider OAuth CLI. +//! Black-box regressions for the shipped provider OAuth CLI. //! -//! These cases terminate before browser launch, provider network I/O, credential-store access, or -//! cloud mutation. The Windows case performs only the read-only `--list` action in an isolated -//! temporary profile. +//! These cases stop before browser launch, provider network I/O, credential-store mutation, cloud +//! write, or source eviction. Connection-document cases use only isolated local filesystem state. -use std::process::Command; +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_oauth::{requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use std::process::{Command, Output}; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; fn command() -> Command { Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) } +fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> String { + let mut hasher = Sha256::new(); + for value in [provider.as_str(), root_id, root_path] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn google_connection(root_path: &Path) -> OAuthConnection { + let root_path = root_path.to_string_lossy().into_owned(); + OAuthConnection { + connection_id: connection_id(CloudProvider::GoogleDrive, "google-account", &root_path), + provider: CloudProvider::GoogleDrive, + cloud_root_id: "google-account".into(), + cloud_root_path: root_path, + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(CloudProvider::GoogleDrive) + .expect("Google Drive exposes its read-only scope") + .into(), + connected_at_ms: 123, + } +} + +fn write_private_document(path: &Path, connections: &[OAuthConnection]) { + let document = serde_json::json!({"version": 1, "connections": connections}); + std::fs::write( + path, + serde_json::to_vec(&document).expect("connection document serializes"), + ) + .expect("connection document writes"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("connection document remains private"); + } +} + +fn run_list(home: &Path, connections: &Path) -> Output { + command() + .arg("--home") + .arg(home) + .arg("--connections") + .arg(connections) + .arg("--list") + .output() + .expect("provider OAuth CLI starts") +} + #[test] fn sole_help_is_a_successful_stdout_contract() { let output = command() @@ -38,6 +97,44 @@ fn help_mixed_with_domain_arguments_is_a_bounded_failure() { assert_eq!(output.stderr, b"help must be used alone\n"); } +#[test] +fn read_only_list_serializes_a_valid_connection_without_secret_or_mutation_claims() { + let temp = tempfile::tempdir().expect("isolated app-data root exists"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = temp.path().join("connections.json"); + write_private_document(&document, std::slice::from_ref(&connection)); + + let output = run_list(temp.path(), &document); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["secrets_included"], false); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + +#[test] +fn read_only_list_rejects_duplicate_identity_without_partial_stdout() { + let temp = tempfile::tempdir().expect("isolated app-data root exists"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = temp.path().join("connections.json"); + write_private_document(&document, &[connection.clone(), connection]); + + let output = run_list(temp.path(), &document); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"oauth-connection-document-duplicate-id\n"); +} + #[cfg(windows)] #[test] fn read_only_list_falls_back_to_userprofile_when_home_is_absent() { From 2583e89c6bc17a61625dcc6847224b02d094c367 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:33:02 +0900 Subject: [PATCH 083/157] test(oauth): preserve native non-UTF8 path operands --- ...rovider_oauth_cli_host_process_coverage.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs index df91b3fc2..1718f64aa 100644 --- a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs +++ b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs @@ -158,6 +158,41 @@ fn read_only_list_falls_back_to_userprofile_when_home_is_absent() { assert_eq!(value["source_eviction_executed"], false); } +#[cfg(unix)] +#[test] +fn native_non_utf8_filesystem_arguments_remain_lossless() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let temp = tempfile::tempdir().expect("isolated native-path root exists"); + let home = temp + .path() + .join(OsString::from_vec(vec![b'h', b'o', b'm', b'e', b'-', 0xff])); + std::fs::create_dir(&home).expect("native non-UTF-8 home exists"); + let connections = home.join(OsString::from_vec(vec![ + b'c', b'o', b'n', b'n', b'e', b'c', b't', b'i', b'o', b'n', b's', b'-', 0xfe, b'.', b'j', + b's', b'o', b'n', + ])); + + let output = command() + .arg("--home") + .arg(&home) + .arg("--connections") + .arg(&connections) + .arg("--list") + .output() + .expect("provider OAuth CLI starts"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 0); + assert_eq!(value["connection_document_effect"], "none"); + assert!(!connections.exists(), "read-only list must not create the document"); +} + #[cfg(unix)] #[test] fn non_utf8_host_argument_fails_without_panic_or_reflection() { From f67d8daac43cea23741cae7ed9f862e9868ad679 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:34:58 +0900 Subject: [PATCH 084/157] fix(oauth): preserve native filesystem path operands --- src-tauri/src/bin/disksage-provider-oauth.rs | 94 +++++++++++++++++--- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/src/bin/disksage-provider-oauth.rs index fb1ad690c..67edec17c 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ b/src-tauri/src/bin/disksage-provider-oauth.rs @@ -398,7 +398,7 @@ fn execute(args: Args) -> Result { #[derive(Debug, Clone, PartialEq, Eq)] enum TerminalArgs { Help, - Run(Vec), + Run(Vec), } #[cfg(not(coverage))] @@ -408,9 +408,6 @@ fn is_help(value: &OsStr) -> bool { #[cfg(not(coverage))] fn parse_terminal_args(args: Vec) -> Result { - if args.iter().any(|value| value.to_str().is_none()) { - return Err("argument-encoding-invalid".into()); - } match args.as_slice() { [only] if is_help(only) => return Ok(TerminalArgs::Help), values if values.iter().any(|value| is_help(value)) => { @@ -418,15 +415,84 @@ fn parse_terminal_args(args: Vec) -> Result { } _ => {} } - Ok(TerminalArgs::Run( - args.into_iter() - .map(|value| { - value - .into_string() - .expect("non-UTF-8 arguments were rejected before domain parsing") - }) - .collect(), - )) + Ok(TerminalArgs::Run(args)) +} + +#[cfg(not(coverage))] +fn host_path_surrogate(path: &Path) -> &'static str { + if path.is_absolute() { + #[cfg(windows)] + return "C:\\"; + #[cfg(not(windows))] + return "/"; + } + "relative" +} + +#[cfg(not(coverage))] +fn parse_host_args(args: Vec, environment_home: Option) -> Result { + let mut normalized = Vec::with_capacity(args.len()); + let mut native_home = Vec::new(); + let mut native_connections = Vec::new(); + let mut native_cloud_root = Vec::new(); + let mut index = 0usize; + + while index < args.len() { + let option = args[index] + .to_str() + .ok_or_else(|| "argument-encoding-invalid".to_string())?; + match option { + "--home" | "--connections" | "--cloud-root" => { + normalized.push(option.to_string()); + index += 1; + let raw = args + .get(index) + .ok_or_else(|| format!("{option} requires a value"))?; + let path = PathBuf::from(raw); + normalized.push(host_path_surrogate(&path).to_string()); + match option { + "--home" => native_home.push(path), + "--connections" => native_connections.push(path), + "--cloud-root" => native_cloud_root.push(path), + _ => unreachable!("path option match is exhaustive"), + } + } + "--client-id" => { + normalized.push(option.to_string()); + index += 1; + let raw = args + .get(index) + .ok_or_else(|| "--client-id requires a value".to_string())?; + normalized.push( + raw.to_str() + .ok_or_else(|| "argument-encoding-invalid".to_string())? + .to_string(), + ); + } + "--list" | "--connect" | "--verify-capacity" | "--disconnect" + | "--manual-browser" | "--write-access" | "--help" | "-h" => { + normalized.push(option.to_string()); + } + _ => return Err("unknown argument".into()), + } + index += 1; + } + + let explicit_home = !native_home.is_empty(); + let explicit_connections = !native_connections.is_empty(); + let mut parsed = parse_args(&normalized, environment_home)?; + if let Some(home) = native_home.into_iter().next() { + parsed.home = home; + } + if let Some(connections) = native_connections.into_iter().next() { + parsed.connections = connections; + } else if explicit_home && !explicit_connections { + parsed.connections = default_connections_path(&parsed.home); + } + if let Some(cloud_root) = native_cloud_root.into_iter().next() { + parsed.cloud_root = Some(cloud_root); + } + Ok(parsed) } #[cfg(not(coverage))] @@ -474,7 +540,7 @@ fn run() -> Result<(), String> { } TerminalArgs::Run(args) => args, }; - let parsed = parse_args(&args, environment_home())?; + let parsed = parse_host_args(args, environment_home())?; let output = execute(parsed)?; println!( "{}", From ba95612242fb1f0d52e6cc2d2501b3794d459bb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:38:57 +0900 Subject: [PATCH 085/157] test(oauth-cli): preserve native filesystem path operands --- src-tauri/tests/provider_oauth_cli_process.rs | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs index ae035233e..5925ebfee 100644 --- a/src-tauri/tests/provider_oauth_cli_process.rs +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -1,11 +1,9 @@ #![cfg(feature = "cloud-cli")] -//! Black-box Windows regression for the shipped provider OAuth CLI home-authority boundary. +//! Black-box regressions for the shipped provider OAuth CLI host-authority boundary. //! -//! The platform-neutral `environment_home_from` contract is not enough to prove that the packaged -//! process actually observes Windows `USERPROFILE` when `HOME` is absent. This test launches the -//! real feature-gated binary and stays on the read-only `--list` path, so it performs no browser, -//! network, credential-store, provider-write, or source-eviction work. +//! These tests launch the real feature-gated binary and stay on the read-only `--list` path, so +//! they perform no browser, network, credential-store, provider-write, or source-eviction work. #[cfg(windows)] #[test] @@ -32,3 +30,46 @@ fn read_only_list_uses_userprofile_when_home_is_absent() { assert_eq!(value["cloud_write_executed"], false); assert_eq!(value["source_eviction_executed"], false); } + +#[cfg(unix)] +#[test] +fn read_only_list_preserves_native_non_utf8_path_operands() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use std::process::Command; + + let temp = tempfile::tempdir().expect("temporary native-path root should be created"); + let home = temp + .path() + .join(OsString::from_vec(vec![b'h', b'o', b'm', b'e', b'-', 0xff])); + std::fs::create_dir(&home).expect("native non-UTF-8 home should be created"); + let connections = home.join(OsString::from_vec(vec![ + b'c', b'o', b'n', b'n', b'e', b'c', b't', b'i', b'o', b'n', b's', b'-', 0xfe, b'.', b'j', + b's', b'o', b'n', + ])); + + let output = Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) + .arg("--home") + .arg(&home) + .arg("--connections") + .arg(&connections) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 0); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["secrets_included"], false); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); + assert!( + !connections.exists(), + "read-only list must not create the connection document" + ); +} From 45a7dbc1e3524a5154bfc1104159b72ef4fbc7c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:39:26 +0900 Subject: [PATCH 086/157] fix(oauth-cli): keep filesystem operands platform-native --- .../src/bin/disksage-provider-oauth-entry.rs | 98 ++++++++++++++++--- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 1486487ce..3b9c16c06 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -4,9 +4,12 @@ //! this real entrypoint. This entry owns host argument decoding, terminal help, and platform //! home-directory selection before delegating to the existing OAuth execution boundary. -use std::path::PathBuf; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; mod implementation { + use super::{OsString, Path, PathBuf}; + include!(concat!( env!("CARGO_MANIFEST_DIR"), "/provider_oauth_cli_impl.rs.inc" @@ -17,12 +20,84 @@ mod implementation { usage() } + #[cfg(not(coverage))] + fn host_path_surrogate(path: &Path) -> &'static str { + if path.is_absolute() { + #[cfg(windows)] + return "C:\\"; + #[cfg(not(windows))] + return "/"; + } + "relative" + } + #[cfg(not(coverage))] pub(super) fn run_with_environment_home( - args: &[String], + args: Vec, environment_home: Option, ) -> Result<(), String> { - let parsed = parse_args(args, environment_home)?; + let mut normalized = Vec::with_capacity(args.len()); + let mut native_home = Vec::new(); + let mut native_connections = Vec::new(); + let mut native_cloud_root = Vec::new(); + let mut index = 0usize; + + while index < args.len() { + let option = args[index] + .to_str() + .ok_or_else(|| "provider-oauth-invalid-utf8-argument".to_string())?; + match option { + "--home" | "--connections" | "--cloud-root" => { + normalized.push(option.to_string()); + index += 1; + let raw = args + .get(index) + .ok_or_else(|| format!("{option} requires a value"))?; + let path = PathBuf::from(raw); + normalized.push(host_path_surrogate(&path).to_string()); + match option { + "--home" => native_home.push(path), + "--connections" => native_connections.push(path), + "--cloud-root" => native_cloud_root.push(path), + _ => unreachable!("path option match is exhaustive"), + } + } + "--client-id" => { + normalized.push(option.to_string()); + index += 1; + let raw = args + .get(index) + .ok_or_else(|| "--client-id requires a value".to_string())?; + normalized.push( + raw.to_str() + .ok_or_else(|| "provider-oauth-invalid-utf8-argument".to_string())? + .to_string(), + ); + } + "--list" | "--connect" | "--verify-capacity" | "--disconnect" + | "--manual-browser" | "--write-access" | "--help" | "-h" => { + normalized.push(option.to_string()); + } + _ => return Err("unknown argument".into()), + } + index += 1; + } + + let explicit_home = !native_home.is_empty(); + let explicit_connections = !native_connections.is_empty(); + let mut parsed = parse_args(&normalized, environment_home)?; + if let Some(home) = native_home.into_iter().next() { + parsed.home = home; + } + if let Some(connections) = native_connections.into_iter().next() { + parsed.connections = connections; + } else if explicit_home && !explicit_connections { + parsed.connections = default_connections_path(&parsed.home); + } + if let Some(cloud_root) = native_cloud_root.into_iter().next() { + parsed.cloud_root = Some(cloud_root); + } + let output = execute(parsed)?; println!( "{}", @@ -51,21 +126,14 @@ pub(crate) fn environment_home_from( } #[cfg(not(coverage))] -fn command_line_args() -> Result, String> { - std::env::args_os() - .skip(1) - .map(|argument| { - argument - .into_string() - .map_err(|_| "provider-oauth-invalid-utf8-argument".to_string()) - }) - .collect() +fn command_line_args() -> Vec { + std::env::args_os().skip(1).collect() } #[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = command_line_args()?; - if matches!(args.as_slice(), [flag] if flag == "--help" || flag == "-h") { + let args = command_line_args(); + if matches!(args.as_slice(), [flag] if flag == OsStr::new("--help") || flag == OsStr::new("-h")) { println!("{}", implementation::usage_text()); return Ok(()); } @@ -75,7 +143,7 @@ fn run() -> Result<(), String> { std::env::var_os("USERPROFILE").map(PathBuf::from), cfg!(windows), ); - implementation::run_with_environment_home(&args, environment_home) + implementation::run_with_environment_home(args, environment_home) } #[cfg(not(coverage))] From 22f6af5b22a172b0b9a2e074e06480d52fc60a26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:39:49 +0900 Subject: [PATCH 087/157] fix(oauth-cli): keep host adapter imports canonical --- src-tauri/src/bin/disksage-provider-oauth-entry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 3b9c16c06..6221524a7 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -5,10 +5,10 @@ //! home-directory selection before delegating to the existing OAuth execution boundary. use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; mod implementation { - use super::{OsString, Path, PathBuf}; + use super::OsString; include!(concat!( env!("CARGO_MANIFEST_DIR"), From 3dfaf475fff16732127a973f3cbacd0571c7cf40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:40:37 +0900 Subject: [PATCH 088/157] repair(oauth): return CLI host boundary to canonical owner --- src-tauri/src/bin/disksage-provider-oauth.rs | 179 +-------------- ...rovider_oauth_cli_host_adapter_coverage.rs | 7 - ...rovider_oauth_cli_host_process_coverage.rs | 211 ------------------ 3 files changed, 3 insertions(+), 394 deletions(-) delete mode 100644 src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs delete mode 100644 src-tauri/tests/provider_oauth_cli_host_process_coverage.rs diff --git a/src-tauri/src/bin/disksage-provider-oauth.rs b/src-tauri/src/bin/disksage-provider-oauth.rs index 67edec17c..eb16b49dd 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ b/src-tauri/src/bin/disksage-provider-oauth.rs @@ -4,8 +4,6 @@ //! non-secret connection descriptors and provider capacity evidence; this command itself never //! performs a cloud file write or source eviction. -#[cfg(not(coverage))] -use std::ffi::{OsStr, OsString}; #[cfg(not(coverage))] use std::path::{Path, PathBuf}; #[cfg(not(coverage))] @@ -18,10 +16,6 @@ use disksage_lib::provider_capacity::{self, FixedHostProviderCapacityClient}; #[cfg(not(coverage))] use disksage_lib::provider_oauth::{self, OAuthConnection}; -#[cfg(not(coverage))] -#[path = "../home_resolution.rs"] -mod home_resolution; - #[cfg(not(coverage))] const OUTPUT_SCHEMA_VERSION: u32 = 1; #[cfg(not(coverage))] @@ -394,153 +388,10 @@ fn execute(args: Args) -> Result { } } -#[cfg(not(coverage))] -#[derive(Debug, Clone, PartialEq, Eq)] -enum TerminalArgs { - Help, - Run(Vec), -} - -#[cfg(not(coverage))] -fn is_help(value: &OsStr) -> bool { - value == OsStr::new("--help") || value == OsStr::new("-h") -} - -#[cfg(not(coverage))] -fn parse_terminal_args(args: Vec) -> Result { - match args.as_slice() { - [only] if is_help(only) => return Ok(TerminalArgs::Help), - values if values.iter().any(|value| is_help(value)) => { - return Err("help must be used alone".into()); - } - _ => {} - } - Ok(TerminalArgs::Run(args)) -} - -#[cfg(not(coverage))] -fn host_path_surrogate(path: &Path) -> &'static str { - if path.is_absolute() { - #[cfg(windows)] - return "C:\\"; - #[cfg(not(windows))] - return "/"; - } - "relative" -} - -#[cfg(not(coverage))] -fn parse_host_args(args: Vec, environment_home: Option) -> Result { - let mut normalized = Vec::with_capacity(args.len()); - let mut native_home = Vec::new(); - let mut native_connections = Vec::new(); - let mut native_cloud_root = Vec::new(); - let mut index = 0usize; - - while index < args.len() { - let option = args[index] - .to_str() - .ok_or_else(|| "argument-encoding-invalid".to_string())?; - match option { - "--home" | "--connections" | "--cloud-root" => { - normalized.push(option.to_string()); - index += 1; - let raw = args - .get(index) - .ok_or_else(|| format!("{option} requires a value"))?; - let path = PathBuf::from(raw); - normalized.push(host_path_surrogate(&path).to_string()); - match option { - "--home" => native_home.push(path), - "--connections" => native_connections.push(path), - "--cloud-root" => native_cloud_root.push(path), - _ => unreachable!("path option match is exhaustive"), - } - } - "--client-id" => { - normalized.push(option.to_string()); - index += 1; - let raw = args - .get(index) - .ok_or_else(|| "--client-id requires a value".to_string())?; - normalized.push( - raw.to_str() - .ok_or_else(|| "argument-encoding-invalid".to_string())? - .to_string(), - ); - } - "--list" | "--connect" | "--verify-capacity" | "--disconnect" - | "--manual-browser" | "--write-access" | "--help" | "-h" => { - normalized.push(option.to_string()); - } - _ => return Err("unknown argument".into()), - } - index += 1; - } - - let explicit_home = !native_home.is_empty(); - let explicit_connections = !native_connections.is_empty(); - let mut parsed = parse_args(&normalized, environment_home)?; - if let Some(home) = native_home.into_iter().next() { - parsed.home = home; - } - if let Some(connections) = native_connections.into_iter().next() { - parsed.connections = connections; - } else if explicit_home && !explicit_connections { - parsed.connections = default_connections_path(&parsed.home); - } - if let Some(cloud_root) = native_cloud_root.into_iter().next() { - parsed.cloud_root = Some(cloud_root); - } - Ok(parsed) -} - -#[cfg(not(coverage))] -fn environment_home_from( - home: Option, - user_profile: Option, - windows_home_drive_path: Option, - windows: bool, -) -> Option { - let candidates = if windows { - vec![home, user_profile, windows_home_drive_path] - } else { - vec![home] - }; - home_resolution::select_absolute_home(candidates).ok() -} - -#[cfg(all(not(coverage), windows))] -fn environment_home() -> Option { - environment_home_from( - std::env::var_os("HOME").map(PathBuf::from), - std::env::var_os("USERPROFILE").map(PathBuf::from), - home_resolution::windows_home_drive_path(), - true, - ) -} - -#[cfg(all(not(coverage), not(windows)))] -fn environment_home() -> Option { - environment_home_from( - std::env::var_os("HOME").map(PathBuf::from), - None, - None, - false, - ) -} - #[cfg(not(coverage))] fn run() -> Result<(), String> { - let terminal_args = parse_terminal_args(std::env::args_os().skip(1).collect())?; - let args = match terminal_args { - TerminalArgs::Help => { - println!("{}", usage()); - return Ok(()); - } - TerminalArgs::Run(args) => args, - }; - let parsed = parse_host_args(args, environment_home())?; + let args = std::env::args().skip(1).collect::>(); + let parsed = parse_args(&args, std::env::var_os("HOME").map(PathBuf::from))?; let output = execute(parsed)?; println!( "{}", @@ -693,7 +544,7 @@ mod tests { home.clone(), ) .is_err()); - assert!(parse_args(&strings(&["--list", "--home", "relative"]), home).is_err()); + assert!(parse_args(&strings(&["--list", "--home", "relative"]), home,).is_err()); } #[test] @@ -750,28 +601,4 @@ mod tests { assert!(encoded.get("access_token").is_none()); assert!(encoded.get("refresh_token").is_none()); } - - #[test] - fn terminal_host_parser_keeps_help_success_separate_from_domain_parsing() { - assert_eq!( - parse_terminal_args(vec![OsString::from("--help")]).unwrap(), - TerminalArgs::Help - ); - assert_eq!( - parse_terminal_args(vec![OsString::from("--help"), OsString::from("--list")]) - .unwrap_err(), - "help must be used alone" - ); - } - - #[test] - fn windows_environment_home_falls_back_to_user_profile_without_importing_it_on_unix() { - let profile = std::env::temp_dir().join("disksage-user-profile"); - let drive_path = std::env::temp_dir().join("disksage-home-drive-path"); - assert_eq!( - environment_home_from(None, Some(profile.clone()), Some(drive_path), true), - Some(profile.clone()) - ); - assert_eq!(environment_home_from(None, Some(profile), None, false), None); - } } diff --git a/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs deleted file mode 100644 index 1bbf23626..000000000 --- a/src-tauri/tests/provider_oauth_cli_host_adapter_coverage.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Compile and execute the provider OAuth CLI's pure host-boundary contracts in the default Rust -//! test suite, independent of provider credentials or feature-gated process execution. - -#![allow(dead_code, unused_imports)] - -#[path = "../src/bin/disksage-provider-oauth.rs"] -mod provider_oauth_cli; diff --git a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs b/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs deleted file mode 100644 index 1718f64aa..000000000 --- a/src-tauri/tests/provider_oauth_cli_host_process_coverage.rs +++ /dev/null @@ -1,211 +0,0 @@ -#![cfg(feature = "cloud-cli")] - -//! Black-box regressions for the shipped provider OAuth CLI. -//! -//! These cases stop before browser launch, provider network I/O, credential-store mutation, cloud -//! write, or source eviction. Connection-document cases use only isolated local filesystem state. - -use disksage_lib::cloud::CloudProvider; -use disksage_lib::provider_oauth::{requested_scope, OAuthConnection}; -use sha2::{Digest, Sha256}; -use std::path::Path; -use std::process::{Command, Output}; - -const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; - -fn command() -> Command { - Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) -} - -fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> String { - let mut hasher = Sha256::new(); - for value in [provider.as_str(), root_id, root_path] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -fn google_connection(root_path: &Path) -> OAuthConnection { - let root_path = root_path.to_string_lossy().into_owned(); - OAuthConnection { - connection_id: connection_id(CloudProvider::GoogleDrive, "google-account", &root_path), - provider: CloudProvider::GoogleDrive, - cloud_root_id: "google-account".into(), - cloud_root_path: root_path, - client_id: GOOGLE_CLIENT_ID.into(), - scope: requested_scope(CloudProvider::GoogleDrive) - .expect("Google Drive exposes its read-only scope") - .into(), - connected_at_ms: 123, - } -} - -fn write_private_document(path: &Path, connections: &[OAuthConnection]) { - let document = serde_json::json!({"version": 1, "connections": connections}); - std::fs::write( - path, - serde_json::to_vec(&document).expect("connection document serializes"), - ) - .expect("connection document writes"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .expect("connection document remains private"); - } -} - -fn run_list(home: &Path, connections: &Path) -> Output { - command() - .arg("--home") - .arg(home) - .arg("--connections") - .arg(connections) - .arg("--list") - .output() - .expect("provider OAuth CLI starts") -} - -#[test] -fn sole_help_is_a_successful_stdout_contract() { - let output = command() - .arg("--help") - .output() - .expect("provider OAuth CLI starts"); - - assert_eq!(output.status.code(), Some(0)); - assert!(output.stderr.is_empty()); - let stdout = String::from_utf8(output.stdout).expect("help is UTF-8"); - assert!(stdout.starts_with("usage: disksage-provider-oauth ")); - assert!(stdout.contains("[--write-access]")); -} - -#[test] -fn help_mixed_with_domain_arguments_is_a_bounded_failure() { - let output = command() - .args(["--help", "--list"]) - .output() - .expect("provider OAuth CLI starts"); - - assert_eq!(output.status.code(), Some(1)); - assert!(output.stdout.is_empty()); - assert_eq!(output.stderr, b"help must be used alone\n"); -} - -#[test] -fn read_only_list_serializes_a_valid_connection_without_secret_or_mutation_claims() { - let temp = tempfile::tempdir().expect("isolated app-data root exists"); - let connection = google_connection(&temp.path().join("cloud-root")); - let document = temp.path().join("connections.json"); - write_private_document(&document, std::slice::from_ref(&connection)); - - let output = run_list(temp.path(), &document); - - assert_eq!(output.status.code(), Some(0)); - assert!(output.stderr.is_empty()); - let value: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); - assert_eq!(value["action"], "list"); - assert_eq!(value["schema_version"], 1); - assert_eq!(value["connection_count"], 1); - assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); - assert_eq!(value["secrets_included"], false); - assert_eq!(value["connection_document_effect"], "none"); - assert_eq!(value["credential_store_effect"], "none"); - assert_eq!(value["cloud_write_executed"], false); - assert_eq!(value["source_eviction_executed"], false); -} - -#[test] -fn read_only_list_rejects_duplicate_identity_without_partial_stdout() { - let temp = tempfile::tempdir().expect("isolated app-data root exists"); - let connection = google_connection(&temp.path().join("cloud-root")); - let document = temp.path().join("connections.json"); - write_private_document(&document, &[connection.clone(), connection]); - - let output = run_list(temp.path(), &document); - - assert_eq!(output.status.code(), Some(1)); - assert!(output.stdout.is_empty()); - assert_eq!(output.stderr, b"oauth-connection-document-duplicate-id\n"); -} - -#[cfg(windows)] -#[test] -fn read_only_list_falls_back_to_userprofile_when_home_is_absent() { - let temp = tempfile::tempdir().expect("isolated Windows profile exists"); - let output = command() - .env_remove("HOME") - .env("USERPROFILE", temp.path()) - .arg("--list") - .output() - .expect("provider OAuth CLI starts"); - - assert_eq!(output.status.code(), Some(0)); - assert!(output.stderr.is_empty()); - let value: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); - assert_eq!(value["action"], "list"); - assert_eq!(value["connection_count"], 0); - assert_eq!(value["connection_document_effect"], "none"); - assert_eq!(value["credential_store_effect"], "none"); - assert_eq!(value["cloud_write_executed"], false); - assert_eq!(value["source_eviction_executed"], false); -} - -#[cfg(unix)] -#[test] -fn native_non_utf8_filesystem_arguments_remain_lossless() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; - - let temp = tempfile::tempdir().expect("isolated native-path root exists"); - let home = temp - .path() - .join(OsString::from_vec(vec![b'h', b'o', b'm', b'e', b'-', 0xff])); - std::fs::create_dir(&home).expect("native non-UTF-8 home exists"); - let connections = home.join(OsString::from_vec(vec![ - b'c', b'o', b'n', b'n', b'e', b'c', b't', b'i', b'o', b'n', b's', b'-', 0xfe, b'.', b'j', - b's', b'o', b'n', - ])); - - let output = command() - .arg("--home") - .arg(&home) - .arg("--connections") - .arg(&connections) - .arg("--list") - .output() - .expect("provider OAuth CLI starts"); - - assert_eq!(output.status.code(), Some(0)); - assert!(output.stderr.is_empty()); - let value: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("list stdout remains machine JSON"); - assert_eq!(value["action"], "list"); - assert_eq!(value["connection_count"], 0); - assert_eq!(value["connection_document_effect"], "none"); - assert!(!connections.exists(), "read-only list must not create the document"); -} - -#[cfg(unix)] -#[test] -fn non_utf8_host_argument_fails_without_panic_or_reflection() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; - - let sensitive = OsString::from_vec(vec![0xff, b'/', b'p', b'r', b'i', b'v', b'a', b't', b'e']); - let output = command() - .arg(sensitive) - .output() - .expect("provider OAuth CLI starts"); - - assert_eq!(output.status.code(), Some(1)); - assert!(output.stdout.is_empty()); - assert_eq!(output.stderr, b"argument-encoding-invalid\n"); -} From d870361942484a5fb7235bd75dfbc26dad92815f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 10:42:37 +0900 Subject: [PATCH 089/157] test(oauth-cli): inherit durable list process evidence --- src-tauri/tests/provider_oauth_cli_process.rs | 124 ++++++++++++++++-- 1 file changed, 110 insertions(+), 14 deletions(-) diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs index 5925ebfee..eb432dfd6 100644 --- a/src-tauri/tests/provider_oauth_cli_process.rs +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -1,17 +1,82 @@ #![cfg(feature = "cloud-cli")] -//! Black-box regressions for the shipped provider OAuth CLI host-authority boundary. +//! Black-box regressions for the shipped provider OAuth CLI host/process boundary. //! -//! These tests launch the real feature-gated binary and stay on the read-only `--list` path, so +//! These tests launch the real feature-gated binary. They stay on the read-only `--list` path, so //! they perform no browser, network, credential-store, provider-write, or source-eviction work. +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_oauth::{requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use std::process::{Command, Output}; + +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) +} + +fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> String { + let mut hasher = Sha256::new(); + for value in [provider.as_str(), root_id, root_path] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn google_connection(root_path: &Path) -> OAuthConnection { + let root_path = root_path.to_string_lossy().into_owned(); + OAuthConnection { + connection_id: connection_id(CloudProvider::GoogleDrive, "google-account", &root_path), + provider: CloudProvider::GoogleDrive, + cloud_root_id: "google-account".into(), + cloud_root_path: root_path, + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(CloudProvider::GoogleDrive) + .expect("Google Drive exposes its read-only scope") + .into(), + connected_at_ms: 123, + } +} + +fn write_private_document(path: &Path, connections: &[OAuthConnection]) { + let document = serde_json::json!({"version": 1, "connections": connections}); + std::fs::write( + path, + serde_json::to_vec(&document).expect("connection document serializes"), + ) + .expect("connection document writes"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("connection document remains private"); + } +} + +fn run_list(home: &Path, connections: &Path) -> Output { + command() + .arg("--home") + .arg(home) + .arg("--connections") + .arg(connections) + .arg("--list") + .output() + .expect("provider OAuth CLI starts") +} + #[cfg(windows)] #[test] fn read_only_list_uses_userprofile_when_home_is_absent() { - use std::process::Command; - let temp = tempfile::tempdir().expect("temporary Windows profile root should be created"); - let output = Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) + let output = command() .env_remove("HOME") .env("USERPROFILE", temp.path()) .arg("--list") @@ -36,7 +101,6 @@ fn read_only_list_uses_userprofile_when_home_is_absent() { fn read_only_list_preserves_native_non_utf8_path_operands() { use std::ffi::OsString; use std::os::unix::ffi::OsStringExt; - use std::process::Command; let temp = tempfile::tempdir().expect("temporary native-path root should be created"); let home = temp @@ -48,14 +112,7 @@ fn read_only_list_preserves_native_non_utf8_path_operands() { b's', b'o', b'n', ])); - let output = Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) - .arg("--home") - .arg(&home) - .arg("--connections") - .arg(&connections) - .arg("--list") - .output() - .expect("provider OAuth CLI should start"); + let output = run_list(&home, &connections); assert_eq!(output.status.code(), Some(0)); assert!(output.stderr.is_empty()); @@ -73,3 +130,42 @@ fn read_only_list_preserves_native_non_utf8_path_operands() { "read-only list must not create the connection document" ); } + +#[test] +fn read_only_list_serializes_valid_nonempty_document_without_secret_or_mutation_claims() { + let temp = tempfile::tempdir().expect("temporary app-data root should be created"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = temp.path().join("connections.json"); + write_private_document(&document, std::slice::from_ref(&connection)); + + let output = run_list(temp.path(), &document); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["connections"][0]["cloud_root_id"], "google-account"); + assert_eq!(value["secrets_included"], false); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + +#[test] +fn read_only_list_rejects_duplicate_identity_without_partial_stdout() { + let temp = tempfile::tempdir().expect("temporary app-data root should be created"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = temp.path().join("connections.json"); + write_private_document(&document, &[connection.clone(), connection]); + + let output = run_list(temp.path(), &document); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!(output.stderr, b"oauth-connection-document-duplicate-id\n"); +} From 9380adb3968746d03630b2e9430df9006a3adb7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:05:34 +0900 Subject: [PATCH 090/157] test(oauth-cli): require XDG data-home authority --- .../provider_oauth_xdg_data_home_process.rs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_xdg_data_home_process.rs diff --git a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs new file mode 100644 index 000000000..51eddc99d --- /dev/null +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -0,0 +1,123 @@ +#![cfg(all(feature = "cloud-cli", unix, not(target_os = "macos")))] + +//! Black-box Linux/XDG regression for the shipped provider OAuth CLI data-home boundary. +//! +//! The test launches only the read-only `--list` action. It performs no browser, network, +//! credential-store, provider-write, source-eviction, or filesystem-mutation operation. + +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_oauth::{requested_scope, OAuthConnection}; +use sha2::{Digest, Sha256}; +use std::path::Path; +use std::process::Command; + +const APP_IDENTIFIER: &str = "com.contextualwisdomlab.disksage"; +const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; + +fn command() -> Command { + Command::new(env!("CARGO_BIN_EXE_disksage-provider-oauth")) +} + +fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> String { + let mut hasher = Sha256::new(); + for value in [provider.as_str(), root_id, root_path] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn google_connection(root_path: &Path) -> OAuthConnection { + let root_path = root_path.to_string_lossy().into_owned(); + OAuthConnection { + connection_id: connection_id(CloudProvider::GoogleDrive, "xdg-google-account", &root_path), + provider: CloudProvider::GoogleDrive, + cloud_root_id: "xdg-google-account".into(), + cloud_root_path: root_path, + client_id: GOOGLE_CLIENT_ID.into(), + scope: requested_scope(CloudProvider::GoogleDrive) + .expect("Google Drive exposes its read-only scope") + .into(), + connected_at_ms: 123, + } +} + +fn write_private_document(path: &Path, connection: &OAuthConnection) { + std::fs::create_dir_all(path.parent().expect("connection document has a parent")) + .expect("XDG app-data parent should be created"); + let document = serde_json::json!({"version": 1, "connections": [connection]}); + std::fs::write( + path, + serde_json::to_vec(&document).expect("connection document serializes"), + ) + .expect("connection document writes"); + + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("connection document remains private"); +} + +#[test] +fn read_only_list_uses_absolute_xdg_data_home_for_default_connection_document() { + let temp = tempfile::tempdir().expect("temporary Linux data-home root should be created"); + let home = temp.path().join("home"); + let xdg_data_home = temp.path().join("xdg-data"); + std::fs::create_dir(&home).expect("HOME fixture should be created"); + + let connection = google_connection(&temp.path().join("cloud-root")); + let document = xdg_data_home + .join(APP_IDENTIFIER) + .join("cloud-oauth-connections.json"); + write_private_document(&document, &connection); + + let output = command() + .env("HOME", &home) + .env("XDG_DATA_HOME", &xdg_data_home) + .env_remove("USERPROFILE") + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + +#[test] +fn relative_xdg_data_home_is_ignored_in_favor_of_home_default() { + let temp = tempfile::tempdir().expect("temporary Linux home should be created"); + let home = temp.path().join("home"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = home + .join(".local/share") + .join(APP_IDENTIFIER) + .join("cloud-oauth-connections.json"); + write_private_document(&document, &connection); + + let output = command() + .env("HOME", &home) + .env("XDG_DATA_HOME", "relative-xdg-data") + .env_remove("USERPROFILE") + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); +} From b2a283ed0a2cf9c1b089a3fa4ef873150ccb065c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:05:54 +0900 Subject: [PATCH 091/157] fix(oauth-cli): honor XDG data-home authority --- .../src/bin/disksage-provider-oauth-entry.rs | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 6221524a7..655d146d6 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -130,14 +130,46 @@ fn command_line_args() -> Vec { std::env::args_os().skip(1).collect() } +/// Inject the XDG user-data location as the implicit connection document only when the caller did +/// not select an explicit path. Relative XDG values are invalid authority and are ignored. +#[cfg(all(not(coverage), unix, not(target_os = "macos")))] +fn apply_xdg_data_home_default_connections(args: &mut Vec) { + if args + .iter() + .any(|argument| argument == OsStr::new("--connections")) + { + return; + } + let Some(raw_data_home) = std::env::var_os("XDG_DATA_HOME") else { + return; + }; + if raw_data_home.is_empty() { + return; + } + let data_home = PathBuf::from(raw_data_home); + if !data_home.is_absolute() { + return; + } + args.push(OsString::from("--connections")); + args.push( + data_home + .join("com.contextualwisdomlab.disksage") + .join("cloud-oauth-connections.json") + .into_os_string(), + ); +} + #[cfg(not(coverage))] fn run() -> Result<(), String> { - let args = command_line_args(); + let mut args = command_line_args(); if matches!(args.as_slice(), [flag] if flag == OsStr::new("--help") || flag == OsStr::new("-h")) { println!("{}", implementation::usage_text()); return Ok(()); } + #[cfg(all(unix, not(target_os = "macos")))] + apply_xdg_data_home_default_connections(&mut args); + let environment_home = environment_home_from( std::env::var_os("HOME").map(PathBuf::from), std::env::var_os("USERPROFILE").map(PathBuf::from), From 04bc9b33be54331c076002304d9279a690b31e01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:08:01 +0900 Subject: [PATCH 092/157] ci(oauth-cli): execute Linux XDG process contract --- .github/workflows/test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d657baa1a..263cb383c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,8 +51,10 @@ jobs: run: cargo test --manifest-path src-tauri/Cargo.toml - name: Headless cloud planner tests run: cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --bin disksage-cloud-plan - - name: Operational cloud CLI help contracts - run: cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --test cli_help_health_oauth_exit + - name: Operational cloud CLI help and Linux data-home contracts + run: | + cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --test cli_help_health_oauth_exit + cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli --test provider_oauth_xdg_data_home_process - name: Exact duplicate audit tests run: | cargo test --manifest-path src-tauri/Cargo.toml --features cloud-cli duplicate_audit From 7abeaab79ab51b5621a46a983a2a629f8a20b1ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:08:54 +0900 Subject: [PATCH 093/157] test(oauth-cli): preserve explicit home over XDG default --- .../provider_oauth_xdg_data_home_process.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs index 51eddc99d..98d039c60 100644 --- a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -95,6 +95,39 @@ fn read_only_list_uses_absolute_xdg_data_home_for_default_connection_document() assert_eq!(value["source_eviction_executed"], false); } +#[test] +fn explicit_home_keeps_its_connection_default_when_xdg_data_home_is_set() { + let temp = tempfile::tempdir().expect("temporary Linux authority root should be created"); + let environment_home = temp.path().join("environment-home"); + let explicit_home = temp.path().join("explicit-home"); + let xdg_data_home = temp.path().join("xdg-data"); + std::fs::create_dir(&environment_home).expect("environment HOME fixture should be created"); + + let connection = google_connection(&temp.path().join("cloud-root")); + let document = explicit_home + .join(".local/share") + .join(APP_IDENTIFIER) + .join("cloud-oauth-connections.json"); + write_private_document(&document, &connection); + + let output = command() + .env("HOME", &environment_home) + .env("XDG_DATA_HOME", &xdg_data_home) + .env_remove("USERPROFILE") + .arg("--home") + .arg(&explicit_home) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); +} + #[test] fn relative_xdg_data_home_is_ignored_in_favor_of_home_default() { let temp = tempfile::tempdir().expect("temporary Linux home should be created"); From 7f9b39ada19431e946477d92638c1791fca42116 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:09:14 +0900 Subject: [PATCH 094/157] fix(oauth-cli): keep explicit home above XDG default --- src-tauri/src/bin/disksage-provider-oauth-entry.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 655d146d6..154d513cf 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -131,13 +131,13 @@ fn command_line_args() -> Vec { } /// Inject the XDG user-data location as the implicit connection document only when the caller did -/// not select an explicit path. Relative XDG values are invalid authority and are ignored. +/// not select an explicit home or connection path. Relative XDG values are invalid authority and +/// are ignored. #[cfg(all(not(coverage), unix, not(target_os = "macos")))] fn apply_xdg_data_home_default_connections(args: &mut Vec) { - if args - .iter() - .any(|argument| argument == OsStr::new("--connections")) - { + if args.iter().any(|argument| { + argument == OsStr::new("--home") || argument == OsStr::new("--connections") + }) { return; } let Some(raw_data_home) = std::env::var_os("XDG_DATA_HOME") else { From e63aec89bf447cb08d241c26a7e7ebc25253a384 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:09:53 +0900 Subject: [PATCH 095/157] test(oauth-cli): require redirected Windows APPDATA authority --- src-tauri/tests/provider_oauth_cli_process.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs index eb432dfd6..1a99f7b4e 100644 --- a/src-tauri/tests/provider_oauth_cli_process.rs +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -11,6 +11,7 @@ use sha2::{Digest, Sha256}; use std::path::Path; use std::process::{Command, Output}; +const APP_IDENTIFIER: &str = "com.contextualwisdomlab.disksage"; const GOOGLE_CLIENT_ID: &str = "1234567890-abcxyz.apps.googleusercontent.com"; fn command() -> Command { @@ -96,6 +97,49 @@ fn read_only_list_uses_userprofile_when_home_is_absent() { assert_eq!(value["source_eviction_executed"], false); } +#[cfg(windows)] +#[test] +fn read_only_list_uses_redirected_roaming_appdata_for_default_connection_document() { + let temp = tempfile::tempdir().expect("temporary Windows authority root should be created"); + let user_profile = temp.path().join("profile"); + let appdata = temp.path().join("redirected-roaming-appdata"); + let app_directory = appdata.join(APP_IDENTIFIER); + std::fs::create_dir_all(&user_profile).expect("USERPROFILE fixture should be created"); + std::fs::create_dir_all(&app_directory).expect("redirected APPDATA fixture should be created"); + + let connection = google_connection(&temp.path().join("cloud-root")); + let document = app_directory.join("cloud-oauth-connections.json"); + write_private_document(&document, std::slice::from_ref(&connection)); + + let output = command() + .env_remove("HOME") + .env("USERPROFILE", &user_profile) + .env("APPDATA", &appdata) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); + assert!( + !user_profile + .join("AppData/Roaming") + .join(APP_IDENTIFIER) + .join("cloud-oauth-connections.json") + .exists(), + "read-only list must not create a stale local-profile connection document" + ); +} + #[cfg(unix)] #[test] fn read_only_list_preserves_native_non_utf8_path_operands() { From edb7d7f91d81de833ca70c2e90f1a473423a4a07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:10:12 +0900 Subject: [PATCH 096/157] fix(oauth-cli): honor redirected Windows APPDATA --- .../src/bin/disksage-provider-oauth-entry.rs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index 154d513cf..f354e33ba 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -130,17 +130,23 @@ fn command_line_args() -> Vec { std::env::args_os().skip(1).collect() } -/// Inject the XDG user-data location as the implicit connection document only when the caller did -/// not select an explicit home or connection path. Relative XDG values are invalid authority and -/// are ignored. -#[cfg(all(not(coverage), unix, not(target_os = "macos")))] -fn apply_xdg_data_home_default_connections(args: &mut Vec) { +/// Inject the platform user-data directory as the implicit connection-document authority only when +/// the caller did not select an explicit home or connection path. Empty or relative environment +/// values never gain filesystem authority. +#[cfg(all( + not(coverage), + any(windows, all(unix, not(target_os = "macos"))) +))] +fn apply_environment_data_home_default_connections( + args: &mut Vec, + raw_data_home: Option, +) { if args.iter().any(|argument| { argument == OsStr::new("--home") || argument == OsStr::new("--connections") }) { return; } - let Some(raw_data_home) = std::env::var_os("XDG_DATA_HOME") else { + let Some(raw_data_home) = raw_data_home else { return; }; if raw_data_home.is_empty() { @@ -167,8 +173,10 @@ fn run() -> Result<(), String> { return Ok(()); } + #[cfg(windows)] + apply_environment_data_home_default_connections(&mut args, std::env::var_os("APPDATA")); #[cfg(all(unix, not(target_os = "macos")))] - apply_xdg_data_home_default_connections(&mut args); + apply_environment_data_home_default_connections(&mut args, std::env::var_os("XDG_DATA_HOME")); let environment_home = environment_home_from( std::env::var_os("HOME").map(PathBuf::from), From be39f169fece55736242817be6633562965d67a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:12:07 +0900 Subject: [PATCH 097/157] docs(oauth): record platform data-home authority --- ...-07-16-cloud-provider-oauth-pkce-design.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md b/docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md index f7a8959ff..5b0005745 100644 --- a/docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md +++ b/docs/superpowers/specs/2026-07-16-cloud-provider-oauth-pkce-design.md @@ -68,8 +68,22 @@ DiskSage displays this before consent. It does not silently fall back to a broad `disksage-provider-oauth` exposes the same Rust PKCE and credential lifecycle outside the Tauri webview so an operator can prepare a headless `disksage-cloud-plan` run without pasting a bearer or -refresh token. The default descriptor path is the DiskSage application-data path; an explicit -`--connections` value must be absolute and can be shared with `disksage-cloud-plan`. +refresh token. An explicit `--connections` value must be absolute and is the highest descriptor-path +authority. An explicit `--home` is next and derives the platform default from that supplied home; +this keeps hermetic operator/test roots independent of ambient environment data-home variables. +Without either explicit option, the shipped host entrypoint resolves the default descriptor path as +follows: + +- Linux/non-macOS Unix: an absolute, non-empty `$XDG_DATA_HOME`, otherwise + `$HOME/.local/share/com.contextualwisdomlab.disksage/cloud-oauth-connections.json`. Relative XDG + values are invalid authority and are ignored. +- Windows: an absolute, non-empty `%APPDATA%` so redirected roaming AppData remains authoritative; + otherwise `%USERPROFILE%\AppData\Roaming\com.contextualwisdomlab.disksage\cloud-oauth-connections.json`. +- macOS: `$HOME/Library/Application Support/com.contextualwisdomlab.disksage/cloud-oauth-connections.json`. + +The entrypoint resolves these process/platform values and passes one explicit path into the OAuth +domain. The domain does not read process-global environment state. `--list` remains read-only: a +missing descriptor returns an empty list and does not create the app-data directory or document. ```bash cargo run --locked --features cloud-cli --bin disksage-provider-oauth -- --list @@ -128,6 +142,8 @@ this design. ## Primary references +- [XDG Base Directory Specification 0.8](https://specifications.freedesktop.org/basedir/latest/) +- [Windows Folder Redirection with Group Policy](https://learn.microsoft.com/en-us/windows-server/storage/folder-redirection/folder-redirection-using-group-policy) - [Microsoft identity platform authorization-code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) - [Microsoft redirect URI restrictions and native loopback behavior](https://learn.microsoft.com/en-us/entra/identity-platform/reply-url) - [Microsoft Graph permission reference](https://learn.microsoft.com/en-us/graph/permissions-reference) From 17e6fadec7a12f729e64ecce9e3083645426d489 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 11:16:24 +0900 Subject: [PATCH 098/157] docs(changelog): record OAuth platform data-home repair --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf76f051..c8c23f19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Fixed +- Resolve the provider OAuth descriptor's platform data-home authority before entering the domain: + honor absolute Linux `$XDG_DATA_HOME` and redirected Windows `%APPDATA%`, ignore invalid relative + environment paths, keep explicit `--connections` and `--home` authoritative, and preserve native + non-UTF-8 filesystem operands while leaving macOS Application Support behavior unchanged. - Reject ontology organize destinations that are relative to the process working directory, named-user tilde paths, or parent-traversal paths; only an absolute destination or a home token (`~`/`~/`, plus native Windows `~\`) can produce a move plan, and literal tildes in absolute @@ -118,4 +122,3 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - Bind the default on-device GGUF model to an immutable upstream revision, exact byte count, and SHA-256 digest; replace whole-model buffering and named sibling staging with bounded streaming into an unnamed same-directory temporary file; ignore and preserve unrelated legacy `.part` paths; refuse destination overwrite with create-new semantics; capture destination ownership from the returned open file handle; re-read and rehash the still-open staging source while copying; flush, sync, re-read, and rehash the destination before final acceptance; reject same-file source or destination mutation; preserve foreign destination replacements through identity-bound cleanup; and keep model installation inside the Rust coverage surface with privacy-safe stable errors and deterministic race regressions. - Persist copy-approval provenance in immutable receipt lineage, reject stale, generic, mismatched, or tampered approvals, and retain explicit backward readability for pre-approval receipt formats. - Generate the npm lockfile in an exact-head validation job with repository contents read-only and dependency lifecycle scripts disabled, bind the artifact to SHA-256 evidence, and grant `contents: write` only to a separate publication job that verifies the same-run artifact and unchanged branch head before committing the lockfile. -- Removed obsolete one-shot repair workflows and patch scripts so repository automation no longer retains dormant write-capable recovery paths. From c3475030d7677406456279ddf0ecfaefbc0929b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:05:21 +0900 Subject: [PATCH 099/157] test: require XDG data-home listing without HOME --- .../provider_oauth_xdg_data_home_process.rs | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs index 98d039c60..004137bea 100644 --- a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -95,6 +95,37 @@ fn read_only_list_uses_absolute_xdg_data_home_for_default_connection_document() assert_eq!(value["source_eviction_executed"], false); } +#[test] +fn read_only_list_can_use_absolute_xdg_data_home_without_home() { + let temp = tempfile::tempdir().expect("temporary Linux data-home root should be created"); + let xdg_data_home = temp.path().join("xdg-data"); + let connection = google_connection(&temp.path().join("cloud-root")); + let document = xdg_data_home + .join(APP_IDENTIFIER) + .join("cloud-oauth-connections.json"); + write_private_document(&document, &connection); + + let output = command() + .env_remove("HOME") + .env_remove("USERPROFILE") + .env("XDG_DATA_HOME", &xdg_data_home) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + #[test] fn explicit_home_keeps_its_connection_default_when_xdg_data_home_is_set() { let temp = tempfile::tempdir().expect("temporary Linux authority root should be created"); @@ -111,7 +142,7 @@ fn explicit_home_keeps_its_connection_default_when_xdg_data_home_is_set() { write_private_document(&document, &connection); let output = command() - .env("HOME", &environment_home) + .env("HOME", &home) .env("XDG_DATA_HOME", &xdg_data_home) .env_remove("USERPROFILE") .arg("--home") @@ -153,4 +184,4 @@ fn relative_xdg_data_home_is_ignored_in_favor_of_home_default() { serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); assert_eq!(value["connection_count"], 1); assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); -} +} \ No newline at end of file From 8a424ca301e8e797603d0082102231c7418868b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:05:40 +0900 Subject: [PATCH 100/157] fix(test): preserve XDG fixture variable --- src-tauri/tests/provider_oauth_xdg_data_home_process.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs index 004137bea..fef129744 100644 --- a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -52,7 +52,7 @@ fn write_private_document(path: &Path, connection: &OAuthConnection) { let document = serde_json::json!({"version": 1, "connections": [connection]}); std::fs::write( path, - serde_json::to_vec(&document).expect("connection document serializes"), + serde_json::to_vec(&connection_document).expect("connection document serializes"), ) .expect("connection document writes"); @@ -142,7 +142,7 @@ fn explicit_home_keeps_its_connection_default_when_xdg_data_home_is_set() { write_private_document(&document, &connection); let output = command() - .env("HOME", &home) + .env("HOME", &environment_home) .env("XDG_DATA_HOME", &xdg_data_home) .env_remove("USERPROFILE") .arg("--home") From 7b13dd9d2db78ab5b05377920b50719e6d3ed31f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:06:04 +0900 Subject: [PATCH 101/157] fix(test): restore XDG document serialization --- src-tauri/tests/provider_oauth_xdg_data_home_process.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs index fef129744..f3ca246e8 100644 --- a/src-tauri/tests/provider_oauth_xdg_data_home_process.rs +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -52,7 +52,7 @@ fn write_private_document(path: &Path, connection: &OAuthConnection) { let document = serde_json::json!({"version": 1, "connections": [connection]}); std::fs::write( path, - serde_json::to_vec(&connection_document).expect("connection document serializes"), + serde_json::to_vec(&document).expect("connection document serializes"), ) .expect("connection document writes"); From 0faac1c05cb3db74508d19681c41baf33057335d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:06:27 +0900 Subject: [PATCH 102/157] test: require APPDATA listing without USERPROFILE --- src-tauri/tests/provider_oauth_cli_process.rs | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs index 1a99f7b4e..b05d050c0 100644 --- a/src-tauri/tests/provider_oauth_cli_process.rs +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -27,7 +27,7 @@ fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> Str hasher .finalize() .iter() - .map(|byte| format!("{byte:02x}")) + .map(|byte| format!("{byte:02h}")) .collect() } @@ -79,6 +79,7 @@ fn read_only_list_uses_userprofile_when_home_is_absent() { let temp = tempfile::tempdir().expect("temporary Windows profile root should be created"); let output = command() .env_remove("HOME") + .env_remove("APPDATA") .env("USERPROFILE", temp.path()) .arg("--list") .output() @@ -140,6 +141,39 @@ fn read_only_list_uses_redirected_roaming_appdata_for_default_connection_documen ); } +#[cfg(windows)] +#[test] +fn read_only_list_can_use_redirected_roaming_appdata_without_userprofile() { + let temp = tempfile::tempdir().expect("temporary Windows authority root should be created"); + let appdata = temp.path().join("redirected-roaming-appdata"); + let app_directory = appdata.join(APP_IDENTIFIER); + std::fs::create_dir_all(&app_directory).expect("redirected APPDATA fixture should be created"); + + let connection = google_connection(&temp.path().join("cloud-root")); + let document = app_directory.join("cloud-oauth-connections.json"); + write_private_document(&document, std::slice::from_ref(&connection)); + + let output = command() + .env_remove("HOME") + .env_remove("USERPROFILE") + .env("APPDATA", &appdata) + .arg("--list") + .output() + .expect("provider OAuth CLI should start"); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("list stdout should remain JSON"); + assert_eq!(value["action"], "list"); + assert_eq!(value["connection_count"], 1); + assert_eq!(value["connections"][0]["connection_id"], connection.connection_id); + assert_eq!(value["connection_document_effect"], "none"); + assert_eq!(value["credential_store_effect"], "none"); + assert_eq!(value["cloud_write_executed"], false); + assert_eq!(value["source_eviction_executed"], false); +} + #[cfg(unix)] #[test] fn read_only_list_preserves_native_non_utf8_path_operands() { @@ -212,4 +246,4 @@ fn read_only_list_rejects_duplicate_identity_without_partial_stdout() { assert_eq!(output.status.code(), Some(1)); assert!(output.stdout.is_empty()); assert_eq!(output.stderr, b"oauth-connection-document-duplicate-id\n"); -} +} \ No newline at end of file From 46f16dda59db48f19e2eac9302ed6a11e6043c45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:07:19 +0900 Subject: [PATCH 103/157] fix(test): restore provider OAuth connection hash fixture --- src-tauri/tests/provider_oauth_cli_process.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_cli_process.rs b/src-tauri/tests/provider_oauth_cli_process.rs index b05d050c0..43fe988a3 100644 --- a/src-tauri/tests/provider_oauth_cli_process.rs +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -27,7 +27,7 @@ fn connection_id(provider: CloudProvider, root_id: &str, root_path: &str) -> Str hasher .finalize() .iter() - .map(|byte| format!("{byte:02h}")) + .map(|byte| format!("{byte:02x}")) .collect() } From fab72c8d731c0091e4b0b2b9e50c14ccf70bc11e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 14:07:54 +0900 Subject: [PATCH 104/157] fix: allow data-home-only OAuth listing --- .../src/bin/disksage-provider-oauth-entry.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/bin/disksage-provider-oauth-entry.rs b/src-tauri/src/bin/disksage-provider-oauth-entry.rs index f354e33ba..50ed6944f 100644 --- a/src-tauri/src/bin/disksage-provider-oauth-entry.rs +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -85,7 +85,20 @@ mod implementation { let explicit_home = !native_home.is_empty(); let explicit_connections = !native_connections.is_empty(); - let mut parsed = parse_args(&normalized, environment_home)?; + let parser_home = if environment_home.is_none() + && !explicit_home + && explicit_connections + && normalized.iter().any(|argument| argument == "--list") + { + native_connections + .first() + .filter(|path| path.is_absolute()) + .and_then(|path| path.parent()) + .map(Path::to_path_buf) + } else { + environment_home + }; + let mut parsed = parse_args(&normalized, parser_home)?; if let Some(home) = native_home.into_iter().next() { parsed.home = home; } @@ -200,4 +213,4 @@ fn main() { } #[cfg(coverage)] -fn main() {} +fn main() {} \ No newline at end of file From 9ec8d17d909a83ccdc83d5cff528716e9ce5d37b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:08:45 +0900 Subject: [PATCH 105/157] test: require descriptor-bound OAuth publication --- ...er_oauth_publication_authority_contract.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_publication_authority_contract.rs diff --git a/src-tauri/tests/provider_oauth_publication_authority_contract.rs b/src-tauri/tests/provider_oauth_publication_authority_contract.rs new file mode 100644 index 000000000..d70569026 --- /dev/null +++ b/src-tauri/tests/provider_oauth_publication_authority_contract.rs @@ -0,0 +1,34 @@ +use std::fs; +use std::path::PathBuf; + +fn provider_oauth_source() -> String { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + fs::read_to_string(manifest_dir.join("src/provider_oauth.rs")) + .expect("provider_oauth.rs must remain readable to its publication contract test") +} + +#[test] +fn oauth_connection_publication_is_descriptor_bound_on_unix() { + let source = provider_oauth_source(); + + assert!( + source.contains("libc::openat("), + "connection-document temporary creation must be relative to a pinned directory descriptor" + ); + assert!( + source.contains("libc::renameat("), + "connection-document replacement must remain relative to the pinned directory descriptor" + ); + assert!( + source.contains("libc::unlinkat("), + "failure cleanup must not be redirected through a replaced pathname ancestor" + ); + assert!( + source.contains("oauth-connection-directory-sync-failed"), + "successful replacement must distinguish file-data sync from containing-directory sync" + ); + assert!( + !source.contains("std::fs::rename(&temporary, path)"), + "pathname rename reintroduces the ancestor-replacement publication race" + ); +} From ac130fa035ca8f4ed809c39f41c171b5b91ac775 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:04:36 +0900 Subject: [PATCH 106/157] test(security): require OAuth publication owner consumption --- ...er_oauth_publication_authority_contract.rs | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src-tauri/tests/provider_oauth_publication_authority_contract.rs b/src-tauri/tests/provider_oauth_publication_authority_contract.rs index d70569026..4559e5963 100644 --- a/src-tauri/tests/provider_oauth_publication_authority_contract.rs +++ b/src-tauri/tests/provider_oauth_publication_authority_contract.rs @@ -8,27 +8,37 @@ fn provider_oauth_source() -> String { } #[test] -fn oauth_connection_publication_is_descriptor_bound_on_unix() { +fn oauth_connection_publication_consumes_the_object_bound_owner() { let source = provider_oauth_source(); assert!( - source.contains("libc::openat("), - "connection-document temporary creation must be relative to a pinned directory descriptor" + source.contains("crate::object_bound_publication::replace_object_bound_bytes"), + "provider OAuth must consume the canonical object-bound replacement primitive" ); assert!( - source.contains("libc::renameat("), - "connection-document replacement must remain relative to the pinned directory descriptor" + source.contains("oauth-connection-directory-sync-failed"), + "containing-directory durability failure must remain a stable OAuth-domain error" ); assert!( - source.contains("libc::unlinkat("), - "failure cleanup must not be redirected through a replaced pathname ancestor" + source.contains("oauth-connection-document-publication-uncertain"), + "post-publication namespace drift must not be reported as a clean rollback" ); assert!( - source.contains("oauth-connection-directory-sync-failed"), - "successful replacement must distinguish file-data sync from containing-directory sync" - ); - assert!( - !source.contains("std::fs::rename(&temporary, path)"), - "pathname rename reintroduces the ancestor-replacement publication race" + source.contains("oauth-connection-document-object-bound-publication-unavailable"), + "platforms without object-bound publication must fail closed without a pathname fallback" ); + + for forbidden in [ + "std::fs::rename(&temporary, path)", + "std::fs::remove_file(&temporary)", + "options.open(&temporary)", + "libc::openat(", + "libc::renameat(", + "libc::unlinkat(", + ] { + assert!( + !source.contains(forbidden), + "provider OAuth must not duplicate or bypass the canonical publication owner: {forbidden}" + ); + } } From 4ec4f8e0dff131d307be887576bed335c394adb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:09:26 +0900 Subject: [PATCH 107/157] fix(security): bind OAuth publication to canonical object authority --- src-tauri/src/provider_oauth.rs | 50 +++++++++++++++++---------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 350ede647..2ae61ef97 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -459,6 +459,30 @@ pub fn load_connections(path: &Path) -> Result, String> { Ok(document.connections) } +fn map_connection_publication_error( + error: crate::object_bound_publication::ObjectBoundReplaceError, +) -> String { + use crate::object_bound_publication::ObjectBoundReplaceError as Error; + + let code = match error { + Error::ParentMissing | Error::ParentUnavailable => "oauth-connection-directory-unavailable", + Error::ParentUnsafe => "oauth-connection-directory-unsafe", + Error::ParentWritableByOthers => "oauth-connection-directory-writable-by-others", + Error::ParentIdentityDrift => "oauth-connection-directory-identity-drift", + Error::NameInvalid => "oauth-connection-document-path-invalid", + Error::TargetUnsafe => "oauth-connection-document-not-regular-file", + Error::TargetUnavailable | Error::RenameFailed => "oauth-connection-document-replace-failed", + Error::TemporaryCreateFailed => "oauth-connection-document-create-failed", + Error::ModeInvalid => "oauth-connection-document-permissions-unsafe", + Error::WriteFailed => "oauth-connection-document-write-failed", + Error::CleanupFailed => "oauth-connection-document-cleanup-failed", + Error::DirectorySyncFailed => "oauth-connection-directory-sync-failed", + Error::PostPublishParentIdentityDrift => "oauth-connection-document-publication-uncertain", + Error::UnsupportedPlatform => "oauth-connection-document-object-bound-publication-unavailable", + }; + code.into() +} + fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), String> { if connections.len() > MAX_CONNECTIONS { return Err("oauth-connection-count-invalid".into()); @@ -485,30 +509,8 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), return Err("oauth-connection-document-not-regular-file".into()); } } - let temporary = parent.join(format!( - ".cloud-oauth-connections.{}.tmp", - random_urlsafe(12)? - )); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&temporary) - .map_err(|_| "oauth-connection-document-create-failed")?; - use std::io::Write as _; - if file.write_all(&encoded).is_err() || file.sync_all().is_err() { - let _ = std::fs::remove_file(&temporary); - return Err("oauth-connection-document-write-failed".into()); - } - if std::fs::rename(&temporary, path).is_err() { - let _ = std::fs::remove_file(&temporary); - return Err("oauth-connection-document-replace-failed".into()); - } - Ok(()) + crate::object_bound_publication::replace_object_bound_bytes(path, &encoded, 0o600) + .map_err(map_connection_publication_error) } pub fn connection_for_root( From 9cbb642f62fe313a920308b54db63a97db245fc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 09:10:26 +0900 Subject: [PATCH 108/157] chore: adopt repaired provider publication owner --- src-tauri/src/provider_oauth.rs | 50 ++++++++++--------- ...er_oauth_publication_authority_contract.rs | 36 ++++++++----- 2 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 350ede647..2ae61ef97 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -459,6 +459,30 @@ pub fn load_connections(path: &Path) -> Result, String> { Ok(document.connections) } +fn map_connection_publication_error( + error: crate::object_bound_publication::ObjectBoundReplaceError, +) -> String { + use crate::object_bound_publication::ObjectBoundReplaceError as Error; + + let code = match error { + Error::ParentMissing | Error::ParentUnavailable => "oauth-connection-directory-unavailable", + Error::ParentUnsafe => "oauth-connection-directory-unsafe", + Error::ParentWritableByOthers => "oauth-connection-directory-writable-by-others", + Error::ParentIdentityDrift => "oauth-connection-directory-identity-drift", + Error::NameInvalid => "oauth-connection-document-path-invalid", + Error::TargetUnsafe => "oauth-connection-document-not-regular-file", + Error::TargetUnavailable | Error::RenameFailed => "oauth-connection-document-replace-failed", + Error::TemporaryCreateFailed => "oauth-connection-document-create-failed", + Error::ModeInvalid => "oauth-connection-document-permissions-unsafe", + Error::WriteFailed => "oauth-connection-document-write-failed", + Error::CleanupFailed => "oauth-connection-document-cleanup-failed", + Error::DirectorySyncFailed => "oauth-connection-directory-sync-failed", + Error::PostPublishParentIdentityDrift => "oauth-connection-document-publication-uncertain", + Error::UnsupportedPlatform => "oauth-connection-document-object-bound-publication-unavailable", + }; + code.into() +} + fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), String> { if connections.len() > MAX_CONNECTIONS { return Err("oauth-connection-count-invalid".into()); @@ -485,30 +509,8 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), return Err("oauth-connection-document-not-regular-file".into()); } } - let temporary = parent.join(format!( - ".cloud-oauth-connections.{}.tmp", - random_urlsafe(12)? - )); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&temporary) - .map_err(|_| "oauth-connection-document-create-failed")?; - use std::io::Write as _; - if file.write_all(&encoded).is_err() || file.sync_all().is_err() { - let _ = std::fs::remove_file(&temporary); - return Err("oauth-connection-document-write-failed".into()); - } - if std::fs::rename(&temporary, path).is_err() { - let _ = std::fs::remove_file(&temporary); - return Err("oauth-connection-document-replace-failed".into()); - } - Ok(()) + crate::object_bound_publication::replace_object_bound_bytes(path, &encoded, 0o600) + .map_err(map_connection_publication_error) } pub fn connection_for_root( diff --git a/src-tauri/tests/provider_oauth_publication_authority_contract.rs b/src-tauri/tests/provider_oauth_publication_authority_contract.rs index d70569026..4559e5963 100644 --- a/src-tauri/tests/provider_oauth_publication_authority_contract.rs +++ b/src-tauri/tests/provider_oauth_publication_authority_contract.rs @@ -8,27 +8,37 @@ fn provider_oauth_source() -> String { } #[test] -fn oauth_connection_publication_is_descriptor_bound_on_unix() { +fn oauth_connection_publication_consumes_the_object_bound_owner() { let source = provider_oauth_source(); assert!( - source.contains("libc::openat("), - "connection-document temporary creation must be relative to a pinned directory descriptor" + source.contains("crate::object_bound_publication::replace_object_bound_bytes"), + "provider OAuth must consume the canonical object-bound replacement primitive" ); assert!( - source.contains("libc::renameat("), - "connection-document replacement must remain relative to the pinned directory descriptor" + source.contains("oauth-connection-directory-sync-failed"), + "containing-directory durability failure must remain a stable OAuth-domain error" ); assert!( - source.contains("libc::unlinkat("), - "failure cleanup must not be redirected through a replaced pathname ancestor" + source.contains("oauth-connection-document-publication-uncertain"), + "post-publication namespace drift must not be reported as a clean rollback" ); assert!( - source.contains("oauth-connection-directory-sync-failed"), - "successful replacement must distinguish file-data sync from containing-directory sync" - ); - assert!( - !source.contains("std::fs::rename(&temporary, path)"), - "pathname rename reintroduces the ancestor-replacement publication race" + source.contains("oauth-connection-document-object-bound-publication-unavailable"), + "platforms without object-bound publication must fail closed without a pathname fallback" ); + + for forbidden in [ + "std::fs::rename(&temporary, path)", + "std::fs::remove_file(&temporary)", + "options.open(&temporary)", + "libc::openat(", + "libc::renameat(", + "libc::unlinkat(", + ] { + assert!( + !source.contains(forbidden), + "provider OAuth must not duplicate or bypass the canonical publication owner: {forbidden}" + ); + } } From ef4ff78df5bb34490d48ad8c355f5d605ea3bb43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:12:01 +0900 Subject: [PATCH 109/157] refactor(oauth): adopt private publication facade --- src-tauri/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5aad10ec..0ff9ef584 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -87,8 +87,13 @@ pub mod provider_evidence; pub mod provider_oauth; pub mod provider_global_sync; pub mod provider_sync; +#[path = "private_evidence.rs"] +mod private_evidence_core; +#[path = "private_evidence_publication.rs"] pub mod private_evidence; #[cfg_attr(coverage, allow(dead_code))] +pub(crate) mod private_directory_publication; +#[cfg_attr(coverage, allow(dead_code))] pub(crate) mod object_bound_publication; /// Read-only, fail-closed logical/allocation/reclaimability evidence. pub mod reclaim; From b97388927a5238f978f446dd56b1985d86b5205d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 22:13:56 +0900 Subject: [PATCH 110/157] chore(oauth): preserve canonical publication module registration --- src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ff9ef584..390701c1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,4 +178,4 @@ pub fn run() { ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} \ No newline at end of file +} From f2e0d3b1b8d89fb7819fdc026512b5da566ec069 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:21:27 +0900 Subject: [PATCH 111/157] test(oauth): make private harness compile against publication owner --- src-tauri/src/provider_oauth.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 2ae61ef97..2ebde6b7c 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -1,8 +1,8 @@ -//! Native OAuth 2.0 authorization for cloud-provider metadata checks and explicit file uploads. -//! -//! DiskSage uses the system browser, PKCE S256, an ephemeral loopback listener, exact provider -//! hosts, and an OS credential store. Refresh tokens never enter settings or command responses; -//! access tokens live only long enough to perform one bounded provider operation. +// Native OAuth 2.0 authorization for cloud-provider metadata checks and explicit file uploads. +// +// DiskSage uses the system browser, PKCE S256, an ephemeral loopback listener, exact provider +// hosts, and an OS credential store. Refresh tokens never enter settings or command responses; +// access tokens live only long enough to perform one bounded provider operation. use crate::cloud::{cloud_root_path_matches, CloudProvider, CloudRoot}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; From bb38c014247d9b3cabb0982865039cf78d390870 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:21:54 +0900 Subject: [PATCH 112/157] docs(oauth): preserve module rustdoc for include-safe source --- src-tauri/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 390701c1c..a70086002 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,11 @@ pub mod provider_capacity; pub mod provider_client_runtime; pub mod provider_recovery; pub mod provider_evidence; +/// Native OAuth 2.0 lifecycle for cloud-provider metadata checks and explicit file uploads. +/// +/// DiskSage uses the system browser, PKCE S256, ephemeral loopback listeners, exact provider hosts, +/// and the OS credential store. Refresh tokens never enter settings or command responses; access +/// tokens live only long enough to perform one bounded provider operation. pub mod provider_oauth; pub mod provider_global_sync; pub mod provider_sync; From 4ff7cb0f8265aeea91897060529160275de16c3b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:22:10 +0900 Subject: [PATCH 113/157] test(oauth): bind rollback harness to publication owner --- .../provider_oauth_disconnect_stale_delete_rollback_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs index ff1e83cbc..98abeb63e 100644 --- a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -6,6 +6,8 @@ //! Delete stale legacy credentials first so a stale-delete failure leaves the canonical retry //! credential intact and the restored document remains an honest recovery handle. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From 96f726a8cc34be221d73acc10b498ec2f01855b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:22:25 +0900 Subject: [PATCH 114/157] test(oauth): bind callback harness to publication owner --- src-tauri/tests/provider_oauth_callback_parser_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_callback_parser_coverage.rs b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs index 96a4215a5..549236d1e 100644 --- a/src-tauri/tests/provider_oauth_callback_parser_coverage.rs +++ b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs @@ -7,6 +7,8 @@ //! bounds are exercised without opening a listener, contacting a provider, touching the keyring, //! or publishing durable OAuth metadata. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From 62c4a9c78edd22ae9cde5682e84b8637709cf385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:22:51 +0900 Subject: [PATCH 115/157] test(oauth): bind write-bound harness to publication owner --- ...ider_oauth_connection_document_write_bound_regression.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs index 308224dfc..a3e4ab660 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs @@ -2,10 +2,12 @@ // Compile the production OAuth module into this integration-test crate so the regression can // exercise its private persistence boundary without widening the shipped API surface. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); -// The included production module resolves `crate::cloud`; re-export the shipped cloud types under -// the same crate-local path while keeping the test credential-free and network-free. +// The included production module resolves crate-local production dependencies; re-export the +// shipped cloud types under the same crate-local path while keeping the test credential-free. mod cloud { pub use disksage_lib::cloud::*; } From 05f4eae1dcba2fdf9eed09127cc94f14e1c2ee6e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:23:02 +0900 Subject: [PATCH 116/157] test(oauth): bind disconnect harness to publication owner --- .../tests/provider_oauth_disconnect_all_matching_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs index bac06b23e..30b91c42e 100644 --- a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -4,6 +4,8 @@ //! record that identifies that same root. Leaving a legacy record behind would preserve a local //! connection and credential lookup path after the user was told the provider was disconnected. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From 27ddaa1a04a29689b2658aca2a533ca72b6f5a6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:23:12 +0900 Subject: [PATCH 117/157] test(oauth): stop compiling production module in source-only Windows contract --- ...ider_oauth_windows_atomic_replace_contract.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs b/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs index a3f4e2be3..0585e058b 100644 --- a/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs +++ b/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs @@ -2,17 +2,11 @@ //! Windows durable OAuth metadata replacement must never create a delete-before-publish window. //! -//! `std::fs::rename` is the cross-platform publication primitive used by DiskSage and replaces an -//! existing regular destination on supported Windows filesystems. A separate `remove_file(path)` -//! before that call destroys the last known-good connection document if replacement then fails. -//! Keep the production writer on one replacement primitive and preserve the old document until the -//! new temporary document is ready to replace it. - -include!("../src/provider_oauth.rs"); - -mod cloud { - pub use disksage_lib::cloud::*; -} +//! `std::fs::rename` is the cross-platform publication primitive used by this historical contract +//! and replaces an existing regular destination on supported Windows filesystems. A separate +//! `remove_file(path)` before that call destroys the last known-good connection document if +//! replacement then fails. Keep the regression source-only so it does not duplicate private OAuth +//! production modules in the integration-test crate. #[test] fn production_writer_does_not_predelete_the_durable_destination() { From 8f3fa6a40683b13463620598b607bc478ad847d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:23:22 +0900 Subject: [PATCH 118/157] test(oauth): bind loopback private harness to publication owner --- src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs index 2ba4f58e2..2971a05e2 100644 --- a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs +++ b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs @@ -9,6 +9,8 @@ #![cfg(not(coverage))] #![allow(dead_code, unused_imports)] +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From afe50a76e9c4e79d00f1374c347c2b233ff49d88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:23:45 +0900 Subject: [PATCH 119/157] test(oauth): bind publication authority harness to owner module --- ...ovider_oauth_connection_document_write_authority_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs index 1e226eb3f..9f0f756fe 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -7,6 +7,8 @@ //! boundary without widening the shipped API, opening a browser, contacting a provider, or touching //! the credential store. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From 33c72b37fa3c8297f9562f34d6b5be5b0794ddc2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:24:30 +0900 Subject: [PATCH 120/157] test(oauth): bind reauthorization harness to publication owner --- .../tests/provider_oauth_reauthorization_cleanup_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index a7431f26e..9ead2151f 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -8,6 +8,8 @@ //! connection for normal use. Already-deleted legacy entries may also remain as retry handles: //! keyring `NoEntry` is idempotent success on the next cleanup attempt. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From 3859fc117606692d23a6fc851e6c83e8917bb9cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:25:03 +0900 Subject: [PATCH 121/157] test(oauth): bind token parser harness to publication owner --- .../tests/provider_oauth_token_document_parser_coverage.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs index 0f7af8d18..765818145 100644 --- a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs +++ b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs @@ -6,6 +6,8 @@ //! These regressions include the production module so malformed and boundary responses exercise //! the shipped parser without contacting a provider, opening a browser, or touching the keyring. +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; include!("../src/provider_oauth.rs"); mod cloud { From a25296298b50fcc7f4d208ce3f36dcf41e0e7def Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:33:43 +0900 Subject: [PATCH 122/157] test(oauth): require fail-closed existing document updates --- ...isting_document_replacement_unavailable.rs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs diff --git a/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs b/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs new file mode 100644 index 000000000..c2a12a356 --- /dev/null +++ b/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs @@ -0,0 +1,94 @@ +#![allow(dead_code, unused_imports)] + +//! Consumer contract for provider-OAuth connection-document updates. +//! +//! The OAuth bounded context must not treat a pathname-validated atomic rename as sufficient +//! authority to replace an existing credential-adjacent record. Until the reusable filesystem +//! owner can bind final publication to the exact reviewed source object, an update must fail +//! closed, preserve the accepted document byte-for-byte, and create no staging pathname. + +#[path = "../src/object_bound_publication.rs"] +mod object_bound_publication; +include!("../src/provider_oauth.rs"); + +mod cloud { + pub use disksage_lib::cloud::*; +} + +fn google_root() -> CloudRoot { + #[cfg(windows)] + let path = r"C:\Cloud\replacement-unavailable".to_string(); + #[cfg(not(windows))] + let path = "/Cloud/replacement-unavailable".to_string(); + + CloudRoot { + id: "google-drive:replacement-unavailable".into(), + provider: CloudProvider::GoogleDrive, + account_scope: crate::cloud::CloudAccountScope::Unknown, + label: "Google Drive".into(), + path, + readable: true, + access_issue: None, + } +} + +fn connection(connected_at_ms: u64) -> OAuthConnection { + let root = google_root(); + OAuthConnection { + connection_id: connection_id(&root), + provider: root.provider, + cloud_root_id: root.id, + cloud_root_path: root.path, + client_id: "1234567890-abcxyz.apps.googleusercontent.com".into(), + scope: requested_scope(CloudProvider::GoogleDrive).unwrap().into(), + connected_at_ms, + } +} + +#[cfg(unix)] +#[test] +fn existing_document_update_fails_closed_without_mutating_prior_bytes_or_staging_names() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("tempdir"); + let parent = temp.path().join("oauth"); + std::fs::create_dir(&parent).expect("create parent"); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)) + .expect("private parent"); + let path = parent.join("connections.json"); + + let first = connection(100); + save_connections(&path, std::slice::from_ref(&first)).expect("first create remains available"); + let before = std::fs::read(&path).expect("read initial document"); + let before_names = std::fs::read_dir(&parent) + .expect("read parent") + .map(|entry| entry.expect("entry").file_name()) + .collect::>(); + + let replacement = connection(200); + let error = save_connections(&path, std::slice::from_ref(&replacement)) + .expect_err("existing document replacement must remain unavailable"); + + assert_eq!( + error, + "oauth-connection-document-object-bound-replacement-unavailable" + ); + assert_eq!( + std::fs::read(&path).expect("read preserved document"), + before, + "failed update must preserve the prior accepted OAuth document byte-for-byte" + ); + let after_names = std::fs::read_dir(&parent) + .expect("read parent after refusal") + .map(|entry| entry.expect("entry").file_name()) + .collect::>(); + assert_eq!( + after_names, before_names, + "failed update must not create staging, delete-and-create, or pathname fallback artifacts" + ); + assert_eq!( + load_connections(&path).expect("load preserved document"), + vec![first], + "replacement refusal must leave the accepted connection state unchanged" + ); +} From 60a1e68656197f67aa8b3d8b104f7fec1291af83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:40:29 +0900 Subject: [PATCH 123/157] fix(oauth): fail closed on existing document replacement --- src-tauri/src/provider_oauth.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 2ebde6b7c..b3ec59fd8 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -504,10 +504,15 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), validate_connection_document_parent(parent, true)?; std::fs::create_dir_all(parent).map_err(|_| "oauth-connection-directory-unavailable")?; validate_connection_document_parent(parent, false)?; - if let Ok(metadata) = std::fs::symlink_metadata(path) { - if metadata.file_type().is_symlink() || !metadata.is_file() { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("oauth-connection-document-not-regular-file".into()); } + Ok(_) => { + return Err("oauth-connection-document-object-bound-replacement-unavailable".into()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("oauth-connection-document-unavailable".into()), } crate::object_bound_publication::replace_object_bound_bytes(path, &encoded, 0o600) .map_err(map_connection_publication_error) @@ -635,7 +640,7 @@ fn decode_hex_nibble(value: u8) -> Option { fn percent_decode(value: &str) -> Result { let bytes = value.as_bytes(); - let mut decoded = Vec::with_capacity(bytes.len()); + let mut decoded = Vec::with_capacity(value.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { From 071c67409c962be46a4b9be1e8a292b58dbcf48f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:41:24 +0900 Subject: [PATCH 124/157] test(oauth): align durable update coverage with fail-closed replacement --- ...ction_document_write_authority_coverage.rs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs index 9f0f756fe..ce09d01ed 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -46,7 +46,7 @@ fn connection(id: &str, connected_at_ms: u64) -> OAuthConnection { } #[test] -fn valid_publication_is_private_loadable_and_replaceable() { +fn valid_first_publication_is_private_and_existing_replacement_fails_closed() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("first-use-app-data").join("connections.json"); let first = connection("account-a", 123); @@ -64,14 +64,25 @@ fn valid_publication_is_private_loadable_and_replaceable() { ); } - let mut replacement = first; + let before = std::fs::read(&path).unwrap(); + let parent = path.parent().unwrap(); + let before_names = std::fs::read_dir(parent) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + let mut replacement = first.clone(); replacement.connected_at_ms = 456; - save_connections(&path, std::slice::from_ref(&replacement)).unwrap(); assert_eq!( - load_connections(&path).unwrap(), - vec![replacement], - "replacement must publish the complete new document rather than preserve stale metadata" + save_connections(&path, std::slice::from_ref(&replacement)).unwrap_err(), + "oauth-connection-document-object-bound-replacement-unavailable" ); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(load_connections(&path).unwrap(), vec![first]); + let after_names = std::fs::read_dir(parent) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(after_names, before_names, "replacement refusal must not leave staging names"); } #[cfg(unix)] From adbe7b153534cff8e9d22f0c5d84cb2698dff95e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:42:42 +0900 Subject: [PATCH 125/157] test(oauth): keep disconnect credentials intact when replacement is unavailable --- ..._oauth_disconnect_all_matching_coverage.rs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs index 30b91c42e..854b23c0c 100644 --- a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -1,8 +1,8 @@ #![allow(dead_code, unused_imports)] -//! Disconnecting one canonical File Provider root must remove every durable canonical/legacy -//! record that identifies that same root. Leaving a legacy record behind would preserve a local -//! connection and credential lookup path after the user was told the provider was disconnected. +//! Disconnecting an existing provider requires replacing the durable connection document before +//! credential deletion. Until the filesystem owner can bind that replacement to the exact reviewed +//! source object, the OAuth bounded context must fail before deleting any canonical or legacy token. #[path = "../src/object_bound_publication.rs"] mod object_bound_publication; @@ -50,7 +50,7 @@ fn google_connection( } #[test] -fn disconnect_removes_every_canonical_and_legacy_record_for_the_same_root() { +fn disconnect_fails_before_any_credential_delete_when_document_replacement_is_unavailable() { let temp = tempfile::tempdir().unwrap(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); @@ -59,15 +59,25 @@ fn disconnect_removes_every_canonical_and_legacy_record_for_the_same_root() { let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); let current = google_connection(&saved_root, connection_id(&saved_root), 200); assert_ne!(legacy.connection_id, current.connection_id); - save_connections(&document, &[legacy.clone(), current.clone()]).unwrap(); + let original = vec![legacy, current]; + save_connections(&document, &original).unwrap(); + let before = std::fs::read(&document).unwrap(); let mut deleted = Vec::new(); - disconnect_with_delete(&document, &requested_root, |connection_id| { + let error = disconnect_with_delete(&document, &requested_root, |connection_id| { deleted.push(connection_id.to_string()); Ok(()) }) - .unwrap(); + .unwrap_err(); - assert!(load_connections(&document).unwrap().is_empty()); - assert_eq!(deleted, vec![legacy.connection_id, current.connection_id]); + assert_eq!( + error, + "oauth-connection-document-object-bound-replacement-unavailable" + ); + assert!( + deleted.is_empty(), + "credential deletion must not start before the durable authority update can be published" + ); + assert_eq!(std::fs::read(&document).unwrap(), before); + assert_eq!(load_connections(&document).unwrap(), original); } From 0863aaaa672682961b5d707431713afb580989c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:42:54 +0900 Subject: [PATCH 126/157] test(oauth): fail disconnect before stale credential mutation --- ...sconnect_stale_delete_rollback_coverage.rs | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs index 98abeb63e..d46452021 100644 --- a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -1,10 +1,9 @@ #![allow(dead_code, unused_imports)] -//! A disconnect must not destroy the canonical refresh token before every stale matching -//! credential has been removed. The durable connection document can be restored after a delete -//! failure, but a successfully deleted canonical credential cannot be recreated from that file. -//! Delete stale legacy credentials first so a stale-delete failure leaves the canonical retry -//! credential intact and the restored document remains an honest recovery handle. +//! A disconnect must not delete any canonical or stale refresh token unless the durable connection +//! document can first publish the corresponding state transition. With object-bound replacement +//! unavailable, refusal at the document boundary is the recovery mechanism: credentials and the +//! accepted document both remain unchanged. #[path = "../src/object_bound_publication.rs"] mod object_bound_publication; @@ -52,7 +51,7 @@ fn google_connection( } #[test] -fn stale_credential_delete_failure_preserves_canonical_retry_credential() { +fn replacement_refusal_precedes_stale_and_canonical_credential_deletion() { let temp = tempfile::tempdir().unwrap(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); @@ -61,29 +60,25 @@ fn stale_credential_delete_failure_preserves_canonical_retry_credential() { let legacy = google_connection(&saved_root, legacy_connection_id(&saved_root), 100); let current = google_connection(&saved_root, connection_id(&saved_root), 200); assert_ne!(legacy.connection_id, current.connection_id); - let original = vec![legacy.clone(), current.clone()]; + let original = vec![legacy, current]; save_connections(&document, &original).unwrap(); + let before = std::fs::read(&document).unwrap(); let mut deleted = Vec::new(); let error = disconnect_with_delete(&document, &requested_root, |connection_id| { deleted.push(connection_id.to_string()); - if connection_id == legacy.connection_id { - Err("provider-oauth-keyring-delete-failed".to_string()) - } else { - Ok(()) - } + Err("provider-oauth-keyring-delete-failed".to_string()) }) .unwrap_err(); - assert_eq!(error, "provider-oauth-keyring-delete-failed"); assert_eq!( - deleted, - vec![legacy.connection_id], - "stale matching credentials must be removed before the canonical credential so a stale-delete failure cannot destroy the only usable retry credential" + error, + "oauth-connection-document-object-bound-replacement-unavailable" ); - assert_eq!( - load_connections(&document).unwrap(), - original, - "a partial credential cleanup must restore durable connection state so a retry can finish deleting every matching credential" + assert!( + deleted.is_empty(), + "no keyring credential may be mutated before durable document replacement is authorized" ); + assert_eq!(std::fs::read(&document).unwrap(), before); + assert_eq!(load_connections(&document).unwrap(), original); } From 98fc25a8ae9e4f13d4de31619502760a5628abdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:43:22 +0900 Subject: [PATCH 127/157] test(oauth): bound stale cleanup recovery when replacement is unavailable --- ..._oauth_reauthorization_cleanup_coverage.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index 9ead2151f..90dbdac05 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -4,9 +4,9 @@ //! //! Reauthorization can migrate an NFC/NFD legacy connection to the canonical identifier. If //! deleting a legacy keyring credential fails after the canonical token has been stored, the -//! durable document must retain a retry-visible legacy identity while preferring the canonical -//! connection for normal use. Already-deleted legacy entries may also remain as retry handles: -//! keyring `NoEntry` is idempotent success on the next cleanup attempt. +//! durable document would normally retain a retry-visible legacy identity. While object-bound +//! replacement is unavailable, that recovery publication itself must fail closed rather than +//! overwrite the accepted canonical document through a pathname fallback. #[path = "../src/object_bound_publication.rs"] mod object_bound_publication; @@ -70,7 +70,7 @@ fn google_connection( } #[test] -fn failed_legacy_cleanup_restores_a_retry_visible_identity_beside_the_canonical_connection() { +fn failed_legacy_cleanup_reports_unavailable_retry_publication_and_preserves_canonical_document() { let temp = tempfile::tempdir().unwrap(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); @@ -81,6 +81,7 @@ fn failed_legacy_cleanup_restores_a_retry_visible_identity_beside_the_canonical_ let original = vec![legacy.clone()]; save_connections(&document, std::slice::from_ref(&canonical)).unwrap(); + let before = std::fs::read(&document).unwrap(); let mut deleted = Vec::new(); let error = cleanup_stale_authorization_credentials( @@ -95,16 +96,17 @@ fn failed_legacy_cleanup_restores_a_retry_visible_identity_beside_the_canonical_ ) .unwrap_err(); - assert_eq!(error, "provider-oauth-keyring-delete-failed"); - assert_eq!(deleted, vec![legacy.connection_id.clone()]); - let retry_visible = load_connections(&document).unwrap(); - assert!(retry_visible.contains(&legacy)); - assert!(retry_visible.contains(&canonical)); assert_eq!( - connection_for_root(&retry_visible, &requested_root).unwrap(), - canonical, - "normal use must continue to prefer the newly stored canonical credential while the stale identity remains available for cleanup retry" + error, + "provider-oauth-keyring-delete-and-config-recovery-failed" + ); + assert_eq!(deleted, vec![legacy.connection_id]); + assert_eq!( + std::fs::read(&document).unwrap(), + before, + "failed retry publication must not replace the accepted canonical document" ); + assert_eq!(load_connections(&document).unwrap(), vec![canonical]); } #[test] From 0a38f9e69e4d69cedd9285584c4f5fd0d4cdeeee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 04:51:05 +0900 Subject: [PATCH 128/157] fix(oauth): consume create-new owner and refuse replacement --- src-tauri/src/provider_oauth.rs | 57 +++++++++++++++++---------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index b3ec59fd8..ef709c6fb 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -13,6 +13,14 @@ use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; +#[cfg(test)] +mod provider_oauth_test_private_directory_publication { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/private_directory_publication.rs" + )); +} + #[cfg(not(coverage))] use std::io::Write; #[cfg(not(coverage))] @@ -459,28 +467,26 @@ pub fn load_connections(path: &Path) -> Result, String> { Ok(document.connections) } -fn map_connection_publication_error( - error: crate::object_bound_publication::ObjectBoundReplaceError, -) -> String { - use crate::object_bound_publication::ObjectBoundReplaceError as Error; - - let code = match error { - Error::ParentMissing | Error::ParentUnavailable => "oauth-connection-directory-unavailable", - Error::ParentUnsafe => "oauth-connection-directory-unsafe", - Error::ParentWritableByOthers => "oauth-connection-directory-writable-by-others", - Error::ParentIdentityDrift => "oauth-connection-directory-identity-drift", - Error::NameInvalid => "oauth-connection-document-path-invalid", - Error::TargetUnsafe => "oauth-connection-document-not-regular-file", - Error::TargetUnavailable | Error::RenameFailed => "oauth-connection-document-replace-failed", - Error::TemporaryCreateFailed => "oauth-connection-document-create-failed", - Error::ModeInvalid => "oauth-connection-document-permissions-unsafe", - Error::WriteFailed => "oauth-connection-document-write-failed", - Error::CleanupFailed => "oauth-connection-document-cleanup-failed", - Error::DirectorySyncFailed => "oauth-connection-directory-sync-failed", - Error::PostPublishParentIdentityDrift => "oauth-connection-document-publication-uncertain", - Error::UnsupportedPlatform => "oauth-connection-document-object-bound-publication-unavailable", - }; - code.into() +#[cfg(test)] +fn write_connection_document_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { + provider_oauth_test_private_directory_publication::write_private_bytes_create_new_with_parents( + path, encoded, 0o600, 0o700, + ) +} + +#[cfg(not(test))] +fn write_connection_document_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { + crate::private_directory_publication::write_private_bytes_create_new_with_parents( + path, encoded, 0o600, 0o700, + ) +} + +fn map_connection_create_new_error(error: String) -> String { + if error == "private-directory-publication-unsupported" { + "oauth-connection-document-object-bound-publication-unavailable".into() + } else { + format!("oauth-connection-document-create-failed:{error}") + } } fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), String> { @@ -502,8 +508,6 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), } let parent = connection_document_parent(path); validate_connection_document_parent(parent, true)?; - std::fs::create_dir_all(parent).map_err(|_| "oauth-connection-directory-unavailable")?; - validate_connection_document_parent(parent, false)?; match std::fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("oauth-connection-document-not-regular-file".into()); @@ -514,8 +518,7 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err("oauth-connection-document-unavailable".into()), } - crate::object_bound_publication::replace_object_bound_bytes(path, &encoded, 0o600) - .map_err(map_connection_publication_error) + write_connection_document_create_new(path, &encoded).map_err(map_connection_create_new_error) } pub fn connection_for_root( @@ -640,7 +643,7 @@ fn decode_hex_nibble(value: u8) -> Option { fn percent_decode(value: &str) -> Result { let bytes = value.as_bytes(); - let mut decoded = Vec::with_capacity(value.len()); + let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { From ff21318fafa8cf5e77a158b69c699de5ea3dab79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:03:03 +0900 Subject: [PATCH 129/157] test(oauth): require exact private connection document mode --- .../provider_oauth_leaf_permission_coverage.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs b/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs index 4dc7ad7cc..96a16847b 100644 --- a/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs +++ b/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs @@ -1,16 +1,21 @@ //! Unix privacy-boundary coverage for durable OAuth connection metadata. //! //! A connection document contains provider, client, scope, and cloud-root identity metadata. -//! DiskSage writes new documents as mode 0600 on Unix, so loading a pre-existing document must -//! fail closed if any group or other permission bit exposes or weakens that local metadata. +//! DiskSage creates that record as exact mode 0600 on Unix. Loading must therefore reject not only +//! group/other exposure but any owner-mode or special-bit drift from the admitted durable mode. #[cfg(unix)] #[test] -fn connection_document_requires_private_leaf_permissions() { +fn connection_document_requires_exact_private_leaf_permissions() { use disksage_lib::provider_oauth::load_connections; use std::os::unix::fs::PermissionsExt; - for mode in [0o640, 0o604, 0o620, 0o602] { + for mode in [ + 0o400, // owner-write bit removed + 0o700, // unexpected owner execute bit + 0o640, 0o604, 0o620, 0o602, // group/other exposure or mutation + 0o4600, 0o2600, 0o1600, // setuid, setgid, sticky drift + ] { let temp = tempfile::tempdir().unwrap(); let parent = temp.path().join(format!("oauth-private-parent-{mode:o}")); std::fs::create_dir(&parent).unwrap(); @@ -23,7 +28,7 @@ fn connection_document_requires_private_leaf_permissions() { assert_eq!( load_connections(&path).unwrap_err(), "oauth-connection-document-permissions-unsafe", - "mode {mode:o} must not be admitted as private OAuth metadata" + "mode {mode:o} must not be admitted as exact private OAuth metadata" ); assert_eq!(std::fs::read(&path).unwrap(), original); } From b0c523a822c39e6c44c4c0502e2c35abe919d930 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:08:11 +0900 Subject: [PATCH 130/157] fix(oauth): require exact private connection document mode --- src-tauri/src/provider_oauth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index ef709c6fb..16b659586 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -438,7 +438,7 @@ pub fn load_connections(path: &Path) -> Result, String> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o077 != 0 { + if metadata.permissions().mode() & 0o7777 != 0o600 { return Err("oauth-connection-document-permissions-unsafe".into()); } } From fe2059645790f3d0a2163cbebb16fcf1763089b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:09:29 +0900 Subject: [PATCH 131/157] test(ci): require canonical Windows agent-state regression --- src/lib/testWorkflowPathFilterContract.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index c86981d0c..1acef24f4 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -73,4 +73,12 @@ describe("test workflow path-filter contract", () => { it("does not put negative globs under paths-ignore", () => { expect(negativePathsIgnoreEntries(workflow)).toEqual([]); }); + + it("runs the Windows agent-state regression when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/src/agent_state_guard.rs'"); + expect(workflow).toContain( + "rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe", + ); + expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); + }); }); From 29aaf64c9fea7ffde88fc9a8adcd6f0560c26466 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 23:11:24 +0900 Subject: [PATCH 132/157] fix(ci): own Windows agent-state regression in Test workflow --- .github/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ba638a3b8..e56371a6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,15 @@ jobs: New-Item -ItemType Directory -Force target | Out-Null rustc --edition=2021 --test src-tauri/tests/home_resolution_contract.rs -o target/home-resolution-contract.exe & .\target\home-resolution-contract.exe + - name: Windows agent-state regression when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/src/agent_state_guard.rs') { + rustc --edition=2021 --test src-tauri/src/agent_state_guard.rs -o target/agent-state-guard.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\target\agent-state-guard.exe --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } llm-engine-build: runs-on: ubuntu-latest From f339ee4ad852425b65f6c059b1750238c54db5a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:00:25 +0900 Subject: [PATCH 133/157] fix(ci): run source-present macOS cache owner regressions --- .github/workflows/test.yml | 25 +++++++++++++ .../testWorkflowPathFilterContract.test.ts | 35 ++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e56371a6e..d726ff183 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,6 +73,31 @@ jobs: - run: npm test - run: npm run build + macos-cache-cleanup: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + workspaces: src-tauri + cache-targets: false + - name: macOS cache cleanup regressions when owner source is present + env: + TMPDIR: ${{ runner.temp }} + run: | + for test_name in cache_cleanup_corepack_scope cache_cleanup_cli_permanent_gradle generated_cache_staged_activity; do + if [[ -f "src-tauri/tests/${test_name}.rs" ]]; then + cargo test --manifest-path src-tauri/Cargo.toml --test "$test_name" + else + printf 'SKIP %s: owner test source absent; no runtime regression executed\n' "$test_name" + fi + done + windows-home-resolution: runs-on: windows-latest timeout-minutes: 10 diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 1acef24f4..4bb946cee 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { spawnSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; @@ -82,3 +84,34 @@ describe("test workflow path-filter contract", () => { expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); }); + +// Exercise the canonical shell admission without compiling or faking Rust test results. +it("macOS cache job executes present owner tests, reports absent source, and propagates failure", () => { + const job = workflow.split(" macos-cache-cleanup:\n")[1]?.split(" windows-home-resolution:")[0] ?? ""; + expect(job).toContain("runs-on: macos-latest"); + expect(job).toContain("ref: ${{ github.event.pull_request.head.sha || github.sha }}"); + const script = job.match(/ run: \|\n([\s\S]*)/)?.[1].replace(/^ /gm, "") ?? ""; + for (const target of ["cache_cleanup_corepack_scope", "cache_cleanup_cli_permanent_gradle", "generated_cache_staged_activity"]) { + expect(script).toContain(target); + } + const fixture = mkdtempSync(resolve(tmpdir(), "disksage-workflow-admission-")); + try { + const bin = resolve(fixture, "bin"); + mkdirSync(bin); + const log = resolve(fixture, "cargo.log"); + writeFileSync(resolve(bin, "cargo"), '#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "$CARGO_LOG"\nexit "${CARGO_EXIT:-0}"\n', { mode: 0o700 }); + const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, CARGO_LOG: log }; + const run = (extra = {}) => spawnSync("bash", ["-e", "-c", script], { cwd: fixture, env: { ...env, ...extra }, encoding: "utf8" }); + const absent = run(); + expect(absent.status).toBe(0); + expect(absent.stdout.match(/no runtime regression executed/g)).toHaveLength(3); + expect(existsSync(log)).toBe(false); + mkdirSync(resolve(fixture, "src-tauri/tests"), { recursive: true }); + writeFileSync(resolve(fixture, "src-tauri/tests/generated_cache_staged_activity.rs"), ""); + expect(run().status).toBe(0); + expect(readFileSync(log, "utf8")).toBe("test --manifest-path src-tauri/Cargo.toml --test generated_cache_staged_activity\n"); + expect(run({ CARGO_EXIT: "7" }).status).toBe(7); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); From dad7832cbc20acf8b709b6ed28e06e3db6319b12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 01:02:18 +0900 Subject: [PATCH 134/157] test: quote workflow command fixture correctly --- src/lib/testWorkflowPathFilterContract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index 4bb946cee..c145cc45b 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -99,7 +99,7 @@ it("macOS cache job executes present owner tests, reports absent source, and pro const bin = resolve(fixture, "bin"); mkdirSync(bin); const log = resolve(fixture, "cargo.log"); - writeFileSync(resolve(bin, "cargo"), '#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "$CARGO_LOG"\nexit "${CARGO_EXIT:-0}"\n', { mode: 0o700 }); + writeFileSync(resolve(bin, "cargo"), "#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$CARGO_LOG\"\nexit \"${CARGO_EXIT:-0}\"\n", { mode: 0o700 }); const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, CARGO_LOG: log }; const run = (extra = {}) => spawnSync("bash", ["-e", "-c", script], { cwd: fixture, env: { ...env, ...extra }, encoding: "utf8" }); const absent = run(); From 1bfc2796cf96dfacc60f5a7f23a8554ad3816d59 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:07:46 +0900 Subject: [PATCH 135/157] test: require pre-existing private OAuth parent --- ...oauth_connection_document_write_authority_coverage.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs index ce09d01ed..6ab269c90 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -48,7 +48,14 @@ fn connection(id: &str, connected_at_ms: u64) -> OAuthConnection { #[test] fn valid_first_publication_is_private_and_existing_replacement_fails_closed() { let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("first-use-app-data").join("connections.json"); + let parent = temp.path().join("first-use-app-data"); + std::fs::create_dir(&parent).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let path = parent.join("connections.json"); let first = connection("account-a", 123); save_connections(&path, std::slice::from_ref(&first)).unwrap(); From dafe81757f0cb264ce76a5c8f805ed00e708239b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 18:14:12 +0900 Subject: [PATCH 136/157] test: require canonical OAuth publication owner --- ...ovider_oauth_publication_owner_contract.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src-tauri/tests/provider_oauth_publication_owner_contract.rs diff --git a/src-tauri/tests/provider_oauth_publication_owner_contract.rs b/src-tauri/tests/provider_oauth_publication_owner_contract.rs new file mode 100644 index 000000000..7982d5f65 --- /dev/null +++ b/src-tauri/tests/provider_oauth_publication_owner_contract.rs @@ -0,0 +1,28 @@ +//! Source-owner contract for provider OAuth private-directory publication. +//! +//! OAuth persistence consumes the crate's canonical private-directory publication module. Tests must +//! not `include!` a second module instance, because that would compile a distinct copy of the owner +//! and allow unit-test behavior to drift from the production dependency boundary. + +#[test] +fn provider_oauth_uses_canonical_private_directory_publication_owner() { + let source = include_str!("../src/provider_oauth.rs"); + + assert!( + !source.contains("provider_oauth_test_private_directory_publication"), + "provider OAuth must not compile a test-private copy of the publication owner" + ); + assert!( + !source.contains("/src/private_directory_publication.rs"), + "provider OAuth must consume the crate module instead of include!-copying owner source" + ); + assert_eq!( + source + .matches( + "crate::private_directory_publication::write_private_bytes_create_new_with_parents(" + ) + .count(), + 1, + "provider OAuth must have one canonical private-directory publication call site" + ); +} From eaf1bc88636f573b230ccc71ca5dd4ae78aa370f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 18:23:13 +0900 Subject: [PATCH 137/157] refactor: use canonical OAuth publication owner --- src-tauri/src/provider_oauth.rs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 16b659586..464ce36af 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -13,14 +13,6 @@ use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; use zeroize::Zeroizing; -#[cfg(test)] -mod provider_oauth_test_private_directory_publication { - include!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/src/private_directory_publication.rs" - )); -} - #[cfg(not(coverage))] use std::io::Write; #[cfg(not(coverage))] @@ -467,14 +459,6 @@ pub fn load_connections(path: &Path) -> Result, String> { Ok(document.connections) } -#[cfg(test)] -fn write_connection_document_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { - provider_oauth_test_private_directory_publication::write_private_bytes_create_new_with_parents( - path, encoded, 0o600, 0o700, - ) -} - -#[cfg(not(test))] fn write_connection_document_create_new(path: &Path, encoded: &[u8]) -> Result<(), String> { crate::private_directory_publication::write_private_bytes_create_new_with_parents( path, encoded, 0o600, 0o700, From 871a5279eab08835ce9d170525604e43b6a8620a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 06:17:47 +0900 Subject: [PATCH 138/157] test(provider-oauth): track canonical publication dependency --- src-tauri/tests/provider_oauth_callback_parser_coverage.rs | 4 ++-- ...ider_oauth_connection_document_write_authority_coverage.rs | 4 ++-- ...ovider_oauth_connection_document_write_bound_regression.rs | 4 ++-- .../tests/provider_oauth_disconnect_all_matching_coverage.rs | 4 ++-- ...rovider_oauth_disconnect_stale_delete_rollback_coverage.rs | 4 ++-- ...rovider_oauth_existing_document_replacement_unavailable.rs | 4 ++-- .../tests/provider_oauth_loopback_stream_mode_coverage.rs | 4 ++-- .../tests/provider_oauth_reauthorization_cleanup_coverage.rs | 4 ++-- .../tests/provider_oauth_token_document_parser_coverage.rs | 4 ++-- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src-tauri/tests/provider_oauth_callback_parser_coverage.rs b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs index 549236d1e..4df77b12b 100644 --- a/src-tauri/tests/provider_oauth_callback_parser_coverage.rs +++ b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs @@ -7,8 +7,8 @@ //! bounds are exercised without opening a listener, contacting a provider, touching the keyring, //! or publishing durable OAuth metadata. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs index 6ab269c90..1b8b78d54 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -7,8 +7,8 @@ //! boundary without widening the shipped API, opening a browser, contacting a provider, or touching //! the credential store. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs index a3e4ab660..3c6e734d4 100644 --- a/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs +++ b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs @@ -2,8 +2,8 @@ // Compile the production OAuth module into this integration-test crate so the regression can // exercise its private persistence boundary without widening the shipped API surface. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); // The included production module resolves crate-local production dependencies; re-export the diff --git a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs index 854b23c0c..cdd18bc7a 100644 --- a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -4,8 +4,8 @@ //! credential deletion. Until the filesystem owner can bind that replacement to the exact reviewed //! source object, the OAuth bounded context must fail before deleting any canonical or legacy token. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs index d46452021..108c8c20d 100644 --- a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -5,8 +5,8 @@ //! unavailable, refusal at the document boundary is the recovery mechanism: credentials and the //! accepted document both remain unchanged. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs b/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs index c2a12a356..575e8b396 100644 --- a/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs +++ b/src-tauri/tests/provider_oauth_existing_document_replacement_unavailable.rs @@ -7,8 +7,8 @@ //! owner can bind final publication to the exact reviewed source object, an update must fail //! closed, preserve the accepted document byte-for-byte, and create no staging pathname. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs index 2971a05e2..3e150bd25 100644 --- a/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs +++ b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs @@ -9,8 +9,8 @@ #![cfg(not(coverage))] #![allow(dead_code, unused_imports)] -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index 90dbdac05..8dca46bbe 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -8,8 +8,8 @@ //! replacement is unavailable, that recovery publication itself must fail closed rather than //! overwrite the accepted canonical document through a pathname fallback. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { diff --git a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs index 765818145..455c11c21 100644 --- a/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs +++ b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs @@ -6,8 +6,8 @@ //! These regressions include the production module so malformed and boundary responses exercise //! the shipped parser without contacting a provider, opening a browser, or touching the keyring. -#[path = "../src/object_bound_publication.rs"] -mod object_bound_publication; +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; include!("../src/provider_oauth.rs"); mod cloud { From 9fb90cb16936f3fabca63efb22fd21e012f86242 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 10:07:22 +0900 Subject: [PATCH 139/157] fix(oauth): preserve permission failure taxonomy --- src-tauri/src/provider_oauth.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 464ce36af..b26d958a1 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -404,6 +404,16 @@ fn open_connection_document(path: &Path) -> Result, String Err(error) if error.raw_os_error() == Some(libc::ELOOP) => { Err("oauth-connection-document-not-regular-file".into()) } + #[cfg(unix)] + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("oauth-connection-document-not-regular-file".into()) + } + Ok(_) => Err("oauth-connection-document-unreadable".into()), + Err(_) => Err("oauth-connection-document-unavailable".into()), + } + } Err(_) => Err("oauth-connection-document-unavailable".into()), } } @@ -627,7 +637,7 @@ fn decode_hex_nibble(value: u8) -> Option { fn percent_decode(value: &str) -> Result { let bytes = value.as_bytes(); - let mut decoded = Vec::with_capacity(bytes.len()); + let mut decoded = Vec::with_capacity(value.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { From 6b83caf8d53838854ec445f18e12a62078afad21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 10:13:10 +0900 Subject: [PATCH 140/157] fix(oauth): keep percent decoder allocation unchanged --- src-tauri/src/provider_oauth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index b26d958a1..765e7a9c9 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -637,7 +637,7 @@ fn decode_hex_nibble(value: u8) -> Option { fn percent_decode(value: &str) -> Result { let bytes = value.as_bytes(); - let mut decoded = Vec::with_capacity(value.len()); + let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { match bytes[index] { From 37046e6503f78db97becf0187d45e22f1ee90edf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 10:28:41 +0900 Subject: [PATCH 141/157] test(oauth): align publication contract with create-new owner --- ...er_oauth_publication_authority_contract.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src-tauri/tests/provider_oauth_publication_authority_contract.rs b/src-tauri/tests/provider_oauth_publication_authority_contract.rs index 4559e5963..8f6f4f124 100644 --- a/src-tauri/tests/provider_oauth_publication_authority_contract.rs +++ b/src-tauri/tests/provider_oauth_publication_authority_contract.rs @@ -8,24 +8,26 @@ fn provider_oauth_source() -> String { } #[test] -fn oauth_connection_publication_consumes_the_object_bound_owner() { +fn oauth_connection_publication_uses_create_new_owner_and_refuses_existing_replacement() { let source = provider_oauth_source(); assert!( - source.contains("crate::object_bound_publication::replace_object_bound_bytes"), - "provider OAuth must consume the canonical object-bound replacement primitive" + source.contains( + "crate::private_directory_publication::write_private_bytes_create_new_with_parents(" + ), + "provider OAuth create-new publication must consume the canonical private-directory owner" ); assert!( - source.contains("oauth-connection-directory-sync-failed"), - "containing-directory durability failure must remain a stable OAuth-domain error" + source.contains("oauth-connection-document-object-bound-replacement-unavailable"), + "existing connection documents must fail closed while exact-source replacement authority is unavailable" ); assert!( - source.contains("oauth-connection-document-publication-uncertain"), - "post-publication namespace drift must not be reported as a clean rollback" + source.contains("oauth-connection-document-object-bound-publication-unavailable"), + "platforms without canonical private publication must fail closed without a pathname fallback" ); assert!( - source.contains("oauth-connection-document-object-bound-publication-unavailable"), - "platforms without object-bound publication must fail closed without a pathname fallback" + !source.contains("crate::object_bound_publication::replace_object_bound_bytes"), + "provider OAuth must not reintroduce the superseded replacement owner while existing-record replacement is unavailable" ); for forbidden in [ From 47a5ec0bf2108c297beb426bdd1a5410feeb60ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 10:49:53 +0900 Subject: [PATCH 142/157] test(oauth): use private roundtrip fixture --- src-tauri/src/provider_oauth.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index 765e7a9c9..11638da71 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -1416,6 +1416,15 @@ mod tests { #[test] fn connection_document_round_trips_and_rejects_tampering() { + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[cfg(unix)] + let temp = tempfile::Builder::new() + .permissions(std::fs::Permissions::from_mode(0o700)) + .tempdir() + .unwrap(); + #[cfg(not(unix))] let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("connections.json"); let connections = vec![ From 59ab0004dc7bdd5de9c999f8fcbb02ca567b0e7f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 17:17:37 +0900 Subject: [PATCH 143/157] test(oauth): make reauthorization publication parent private --- ...r_oauth_reauthorization_cleanup_coverage.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs index 8dca46bbe..b7d8791df 100644 --- a/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -16,6 +16,16 @@ mod cloud { pub use disksage_lib::cloud::*; } +fn private_tempdir() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + temp +} + fn unicode_google_root(decomposed: bool) -> CloudRoot { #[cfg(windows)] let composed = r"C:\Cloud\내 드라이브"; @@ -71,7 +81,7 @@ fn google_connection( #[test] fn failed_legacy_cleanup_reports_unavailable_retry_publication_and_preserves_canonical_document() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); let requested_root = unicode_google_root(false); @@ -111,7 +121,7 @@ fn failed_legacy_cleanup_reports_unavailable_retry_publication_and_preserves_can #[test] fn successful_legacy_cleanup_keeps_the_published_document_canonical_only() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); let requested_root = unicode_google_root(false); @@ -139,7 +149,7 @@ fn successful_legacy_cleanup_keeps_the_published_document_canonical_only() { #[test] fn no_stale_identity_never_calls_the_credential_delete_boundary() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let requested_root = unicode_google_root(false); let canonical = google_connection(&requested_root, connection_id(&requested_root), 200); @@ -160,7 +170,7 @@ fn no_stale_identity_never_calls_the_credential_delete_boundary() { #[test] fn failed_retry_visibility_publication_is_reported_separately() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); let requested_root = unicode_google_root(false); From 102b1ded7f6b24af1c5d5729a0c0db0616f1e8fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:16:02 +0900 Subject: [PATCH 144/157] test(oauth): make disconnect publication parent private --- ...rovider_oauth_disconnect_all_matching_coverage.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs index cdd18bc7a..17658a862 100644 --- a/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -12,6 +12,16 @@ mod cloud { pub use disksage_lib::cloud::*; } +fn private_tempdir() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + temp +} + fn unicode_google_root(decomposed: bool) -> CloudRoot { #[cfg(windows)] let composed = r"C:\Cloud\내 드라이브"; @@ -51,7 +61,7 @@ fn google_connection( #[test] fn disconnect_fails_before_any_credential_delete_when_document_replacement_is_unavailable() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); let requested_root = unicode_google_root(false); From 450772c9241594be135c611e105ef49a5dde8ede Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 21:16:36 +0900 Subject: [PATCH 145/157] test(oauth): make rollback publication parent private --- ...auth_disconnect_stale_delete_rollback_coverage.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs index 108c8c20d..24552a92e 100644 --- a/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -13,6 +13,16 @@ mod cloud { pub use disksage_lib::cloud::*; } +fn private_tempdir() -> tempfile::TempDir { + let temp = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + } + temp +} + fn unicode_google_root(decomposed: bool) -> CloudRoot { #[cfg(windows)] let composed = r"C:\Cloud\내 드라이브"; @@ -52,7 +62,7 @@ fn google_connection( #[test] fn replacement_refusal_precedes_stale_and_canonical_credential_deletion() { - let temp = tempfile::tempdir().unwrap(); + let temp = private_tempdir(); let document = temp.path().join("connections.json"); let saved_root = unicode_google_root(true); let requested_root = unicode_google_root(false); From f32eba19df3f8e087cc1390d074d3d844f22ba01 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 14:16:48 +0900 Subject: [PATCH 146/157] test(security): make OAuth object-binding contract rustfmt-stable --- ...r_oauth_connection_document_object_binding_contract.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs index 9211f44ee..86d82c902 100644 --- a/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs +++ b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs @@ -9,6 +9,10 @@ #[test] fn production_reader_is_bound_to_one_open_file_object() { let source = include_str!("../src/provider_oauth.rs"); + let compact_source: String = source + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); assert!( !source.contains("let bytes = std::fs::read(path)"), @@ -23,11 +27,11 @@ fn production_reader_is_bound_to_one_open_file_object() { "Windows connection-document open must inspect the reparse-point object instead of following it" ); assert!( - source.contains("file.metadata()"), + compact_source.contains("letmetadata=file.metadata()"), "regular-file, permission, and size admission must come from the opened object" ); assert!( - source.contains(".take(MAX_CONNECTION_DOCUMENT_BYTES + 1)"), + compact_source.contains("file.take(MAX_CONNECTION_DOCUMENT_BYTES+1)"), "connection-document reads must remain bounded even if the opened file grows after metadata admission" ); } From 13aa16724fb7921c68ae5399138fa4693c823130 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:12:34 +0900 Subject: [PATCH 147/157] test(ci): require provider OAuth Windows process contract --- src/lib/testWorkflowPathFilterContract.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index c145cc45b..ee899930b 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -83,6 +83,13 @@ describe("test workflow path-filter contract", () => { ); expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); + + it("runs the provider OAuth Windows process contract when that owner source is present", () => { + expect(workflow).toContain("Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs'"); + expect(workflow).toContain( + "cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process", + ); + }); }); // Exercise the canonical shell admission without compiling or faking Rust test results. From 8b0e2b529bff0640cef87e2aa7b6c15f40c28655 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:13:09 +0900 Subject: [PATCH 148/157] fix(ci): run provider OAuth process contract on Windows --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c306df497..e8fda855c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,7 +101,7 @@ jobs: windows-home-resolution: runs-on: windows-latest - timeout-minutes: 10 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -123,6 +123,15 @@ jobs: & .\target\agent-state-guard.exe --nocapture if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + - name: Windows provider OAuth process contract when owner source is present + shell: pwsh + run: | + if (Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs') { + cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP provider_oauth_cli_process: owner test source absent; no runtime regression executed' + } llm-engine-build: runs-on: ubuntu-latest From 0e53be704338bb458a85372430d99f760ca17506 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:33:45 +0900 Subject: [PATCH 149/157] test(ci): require explicit agent-state skip evidence --- src/lib/testWorkflowPathFilterContract.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index ee899930b..fc3bfbde5 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -84,6 +84,12 @@ describe("test workflow path-filter contract", () => { expect(workflow).toContain("& .\\target\\agent-state-guard.exe --nocapture"); }); + it("reports absent Windows agent-state source without claiming runtime evidence", () => { + expect(workflow).toContain( + "SKIP agent_state_guard: owner source absent; no runtime regression executed", + ); + }); + it("runs the provider OAuth Windows process contract when that owner source is present", () => { expect(workflow).toContain("Test-Path 'src-tauri/tests/provider_oauth_cli_process.rs'"); expect(workflow).toContain( From e17c2ad0363651559109e4954c0af9c747fb24a5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:34:10 +0900 Subject: [PATCH 150/157] fix(ci): make agent-state source absence explicit --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8fda855c..0d1e9bf08 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,6 +122,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } & .\target\agent-state-guard.exe --nocapture if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + Write-Output 'SKIP agent_state_guard: owner source absent; no runtime regression executed' } - name: Windows provider OAuth process contract when owner source is present shell: pwsh @@ -130,7 +132,7 @@ jobs: cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } else { - Write-Output 'SKIP provider_oauth_cli_process: owner test source absent; no runtime regression executed' + Write-Output 'SKIP provider_oauth_cli_process: owner source absent; no runtime regression executed' } llm-engine-build: From 8a10edc2613431ffd199ccdf2b9751defeafcdcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 23:35:21 +0900 Subject: [PATCH 151/157] test(ci): make exact-head checkout contract lane-extensible --- src/lib/testWorkflowExactHeadContract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/testWorkflowExactHeadContract.test.ts b/src/lib/testWorkflowExactHeadContract.test.ts index 008abb15e..2acdc1e5f 100644 --- a/src/lib/testWorkflowExactHeadContract.test.ts +++ b/src/lib/testWorkflowExactHeadContract.test.ts @@ -13,7 +13,7 @@ describe("Test workflow checkout provenance", () => { line.includes("- uses: actions/checkout@") ? [index] : [], ); - expect(checkoutIndexes).toHaveLength(3); + expect(checkoutIndexes.length).toBeGreaterThanOrEqual(4); for (const checkoutIndex of checkoutIndexes) { const stepIndent = lines[checkoutIndex].match(/^(\s*)/)?.[1] ?? ""; let endIndex = checkoutIndex + 1; From e3d78cd5eaf59fede1c0f87053492bd845701750 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 02:21:54 +0900 Subject: [PATCH 152/157] fix: keep iCloud recovery UID lookup macOS-only --- .../src/bin/disksage-icloud-provider-recovery.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs index b6e90c630..f7d99b0f5 100644 --- a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs +++ b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs @@ -71,6 +71,16 @@ fn now_ms() -> Result { u64::try_from(value).map_err(|_| "system-time-overflow".into()) } +#[cfg(target_os = "macos")] +fn current_user_uid() -> Result { + Ok(unsafe { libc::getuid() }) +} + +#[cfg(not(target_os = "macos"))] +fn current_user_uid() -> Result { + Err("icloud-recovery-platform-unsupported".into()) +} + fn read_plan(path: &Path) -> Result { let metadata = std::fs::symlink_metadata(path) .map_err(|_| "icloud-recovery-plan-unavailable".to_string())?; @@ -104,7 +114,7 @@ fn run() -> Result<(), String> { serde_json::to_value(plan_icloud_file_provider_recovery( &health, daemon, - unsafe { libc::getuid() }, + current_user_uid()?, now, )) } From ca4003bc414d0257c8015145665df8ce504dce28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 02:32:34 +0900 Subject: [PATCH 153/157] test(recovery): expose plan replacement race --- .../bin/disksage-icloud-provider-recovery.rs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs index f7d99b0f5..d14017a85 100644 --- a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs +++ b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs @@ -81,18 +81,29 @@ fn current_user_uid() -> Result { Err("icloud-recovery-platform-unsupported".into()) } -fn read_plan(path: &Path) -> Result { +fn read_plan_with_hook( + path: &Path, + before_read: F, +) -> Result +where + F: FnOnce(), +{ let metadata = std::fs::symlink_metadata(path) .map_err(|_| "icloud-recovery-plan-unavailable".to_string())?; if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 64 * 1024 { return Err("icloud-recovery-plan-unsafe".into()); } + before_read(); serde_json::from_slice( &std::fs::read(path).map_err(|_| "icloud-recovery-plan-read-failed".to_string())?, ) .map_err(|_| "icloud-recovery-plan-json-invalid".into()) } +fn read_plan(path: &Path) -> Result { + read_plan_with_hook(path, || {}) +} + fn run() -> Result<(), String> { let home = std::env::var_os("HOME") .map(PathBuf::from) @@ -170,4 +181,56 @@ mod tests { ) .is_ok()); } + + #[cfg(unix)] + #[test] + fn execution_plan_read_rejects_path_replacement_after_admission() { + use disksage_lib::icloud_provider_recovery::IcloudFileProviderDaemonIdentity; + + fn plan(observed_at_ms: u64) -> IcloudFileProviderRecoveryPlan { + IcloudFileProviderRecoveryPlan { + schema_version: 1, + observed_at_ms, + health_evidence_fingerprint_sha256: "a".repeat(64), + stale_error_count: 1, + oldest_stale_error_age_ms: 15 * 60 * 1_000, + daemon: IcloudFileProviderDaemonIdentity { + uid: 501, + pid: 1234, + service_label: "com.apple.FileProvider".into(), + executable_path: "/System/Library/Frameworks/FileProvider.framework/Support/fileproviderd".into(), + executable_object_id: "b".repeat(64), + apple_signature_valid: true, + }, + blockers: Vec::new(), + eligible: true, + plan_fingerprint_sha256: "c".repeat(64), + exact_approval_phrase: "test approval".into(), + mutation_performed: false, + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let plan_path = temp.path().join("plan.json"); + let replacement_path = temp.path().join("replacement.json"); + let admitted_path = temp.path().join("admitted.json"); + std::fs::write( + &plan_path, + serde_json::to_vec(&plan(1)).expect("serialize admitted plan"), + ) + .expect("write admitted plan"); + std::fs::write( + &replacement_path, + serde_json::to_vec(&plan(2)).expect("serialize replacement plan"), + ) + .expect("write replacement plan"); + + let error = read_plan_with_hook(&plan_path, || { + std::fs::rename(&plan_path, &admitted_path).expect("move admitted object"); + std::fs::rename(&replacement_path, &plan_path).expect("install replacement object"); + }) + .expect_err("path replacement must not switch execution-plan authority"); + + assert_eq!(error, "icloud-recovery-plan-object-changed"); + } } From f6c1d9a79fe83b6ef4dfdb3f6bf3b441bda0e2d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 02:43:45 +0900 Subject: [PATCH 154/157] fix(ci): isolate Ubuntu dependency refresh --- .github/workflows/test.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da9561fc4..506691ff5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,7 +51,12 @@ jobs: persist-credentials: false - name: Install Tauri system deps run: | - sudo apt-get update + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev lsof - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -153,7 +158,12 @@ jobs: persist-credentials: false - name: Install build deps (llama.cpp native + tauri) run: | - sudo apt-get update + for source_file in /etc/apt/sources.list.d/*; do + if [[ -f "$source_file" ]] && grep -q 'dl.google.com/linux/chrome' "$source_file"; then + sudo rm -f "$source_file" + fi + done + sudo apt-get -o Acquire::Retries=3 update sudo apt-get install -y cmake clang libclang-dev libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 @@ -161,4 +171,4 @@ jobs: workspaces: src-tauri cache-targets: false - name: Build with llm-engine (compiles real llama.cpp CPU + engine.rs FFI) - run: cargo test --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run + run: cargo test --manifest-path src-tauri/Cargo.toml --features llm-engine --lib --no-run \ No newline at end of file From f5a74024dc152bc13effc6eb68e200afd583d66d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 02:44:21 +0900 Subject: [PATCH 155/157] test(ci): lock Ubuntu apt isolation contract --- src/lib/testWorkflowPathFilterContract.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts index fc3bfbde5..202856d6e 100644 --- a/src/lib/testWorkflowPathFilterContract.test.ts +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -96,6 +96,13 @@ describe("test workflow path-filter contract", () => { "cargo test --manifest-path src-tauri/Cargo.toml --locked --features cloud-cli --test provider_oauth_cli_process", ); }); + + it("isolates Ubuntu dependency refresh from the hosted runner Chrome repository without weakening apt verification", () => { + expect(workflow.match(/grep -q 'dl\.google\.com\/linux\/chrome'/g)).toHaveLength(2); + expect(workflow.match(/apt-get -o Acquire::Retries=3 update/g)).toHaveLength(2); + expect(workflow).not.toContain("AllowInsecureRepositories"); + expect(workflow).not.toContain("--allow-unauthenticated"); + }); }); // Exercise the canonical shell admission without compiling or faking Rust test results. @@ -127,4 +134,4 @@ it("macOS cache job executes present owner tests, reports absent source, and pro } finally { rmSync(fixture, { recursive: true, force: true }); } -}); +}); \ No newline at end of file From 961e736e7c5ea07ddf30b4dccf120144d79ab311 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:03:09 +0900 Subject: [PATCH 156/157] test(provider): reject malformed bearer credentials --- .../provider_api_bearer_token_contract.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src-tauri/tests/provider_api_bearer_token_contract.rs diff --git a/src-tauri/tests/provider_api_bearer_token_contract.rs b/src-tauri/tests/provider_api_bearer_token_contract.rs new file mode 100644 index 000000000..49c6fadd0 --- /dev/null +++ b/src-tauri/tests/provider_api_bearer_token_contract.rs @@ -0,0 +1,44 @@ +use disksage_lib::cloud::CloudProvider; +use disksage_lib::provider_api_write::{delete_uploaded_object, upload_file}; +use std::path::Path; + +fn upload_error(token: &str) -> String { + upload_file( + CloudProvider::Icloud, + Path::new("local-root"), + Path::new("local-root/archive.bin"), + Path::new("source-does-not-exist"), + 42, + token, + ) + .unwrap_err() +} + +#[test] +fn bearer_credentials_reject_values_outside_rfc_6750_b64token_grammar() { + for token in [ + "token with space", + "token:colon", + "token=padding=inside", + "tokén", + ] { + assert_eq!(upload_error(token), "provider-api-bearer-token-invalid", "{token:?}"); + assert_eq!( + delete_uploaded_object(CloudProvider::Icloud, "object-1", token).unwrap_err(), + "provider-api-bearer-token-invalid", + "{token:?}" + ); + } +} + +#[test] +fn bearer_credentials_keep_rfc_6750_token_characters_and_trailing_padding_admissible() { + for token in ["mF_9.B5f-4.1JqM", "abc+/~._-=="] { + assert_eq!(upload_error(token), "provider-api-icloud-unsupported", "{token:?}"); + assert_eq!( + delete_uploaded_object(CloudProvider::Icloud, "object-1", token).unwrap_err(), + "provider-api-icloud-unsupported", + "{token:?}" + ); + } +} From 1e3f434db9ae9f380f45848e91dfdffd75abd7a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 11 Sep 2026 02:05:23 +0900 Subject: [PATCH 157/157] fix(provider): validate RFC 6750 bearer grammar --- src-tauri/src/provider_api_write.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/provider_api_write.rs b/src-tauri/src/provider_api_write.rs index 4535f6628..d936258e0 100644 --- a/src-tauri/src/provider_api_write.rs +++ b/src-tauri/src/provider_api_write.rs @@ -65,12 +65,28 @@ struct OneDriveUploadProgress { } fn validate_bearer_token(token: &str) -> Result<(), String> { - if token.is_empty() - || token.len() > MAX_BEARER_TOKEN_BYTES - || token.bytes().any(|byte| byte.is_ascii_control()) - { + if token.is_empty() || token.len() > MAX_BEARER_TOKEN_BYTES { return Err("provider-api-bearer-token-invalid".into()); } + + let mut saw_token_character = false; + let mut saw_padding = false; + for byte in token.bytes() { + if byte == b'=' { + if !saw_token_character { + return Err("provider-api-bearer-token-invalid".into()); + } + saw_padding = true; + continue; + } + if saw_padding + || !(byte.is_ascii_alphanumeric() + || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')) + { + return Err("provider-api-bearer-token-invalid".into()); + } + saw_token_character = true; + } Ok(()) }