diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e1594500..506691ff5 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 @@ -49,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 @@ -80,9 +87,34 @@ 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 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -95,6 +127,26 @@ 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 } + } 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 + 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 source absent; no runtime regression executed' + } llm-engine-build: runs-on: ubuntu-latest @@ -106,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 @@ -114,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 393538cf5..32541971a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,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. - Use macOS `NSFileManager` for reversible Trash moves so cleanup does not wait on Finder AppleEvents or inherit a stalled Finder copy queue. - Permit fully current-user-owned real children of the shared Unix temporary root while retaining 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) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ff7cb9e88..54ef626ad 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -147,7 +147,7 @@ path = "src/bin/disksage-container-orphan-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.rs b/src-tauri/provider_oauth_cli_impl.rs.inc similarity index 95% rename from src-tauri/src/bin/disksage-provider-oauth.rs rename to src-tauri/provider_oauth_cli_impl.rs.inc index eb16b49dd..e261170ed 100644 --- a/src-tauri/src/bin/disksage-provider-oauth.rs +++ 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}; @@ -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() } @@ -388,30 +390,6 @@ fn execute(args: Args) -> Result { } } -#[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 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() { - eprintln!("{error}"); - std::process::exit(1); - } -} - -#[cfg(coverage)] -fn main() {} - #[cfg(all(test, not(coverage)))] mod tests { use super::*; diff --git a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs index b6e90c630..d14017a85 100644 --- a/src-tauri/src/bin/disksage-icloud-provider-recovery.rs +++ b/src-tauri/src/bin/disksage-icloud-provider-recovery.rs @@ -71,18 +71,39 @@ fn now_ms() -> Result { u64::try_from(value).map_err(|_| "system-time-overflow".into()) } -fn read_plan(path: &Path) -> Result { +#[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_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) @@ -104,7 +125,7 @@ fn run() -> Result<(), String> { serde_json::to_value(plan_icloud_file_provider_recovery( &health, daemon, - unsafe { libc::getuid() }, + current_user_uid()?, now, )) } @@ -160,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"); + } } diff --git a/src-tauri/src/bin/disksage-icloud-sync-health.rs b/src-tauri/src/bin/disksage-icloud-sync-health.rs index b35d26361..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,11 +44,9 @@ 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()); } - flag => return Err(format!("unknown argument: {flag}")), + _unknown => return Err("icloud-sync-health-unknown-argument".into()), } index += 1; } @@ -84,11 +85,28 @@ 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 = command_line_args()?; + if matches!(cli_args.as_slice(), [flag] if flag == "--help" || flag == "-h") { + println!("{USAGE}"); + 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())?; @@ -104,8 +122,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); } } 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..50ed6944f --- /dev/null +++ b/src-tauri/src/bin/disksage-provider-oauth-entry.rs @@ -0,0 +1,216 @@ +//! Platform-aware entrypoint for the provider OAuth operational CLI. +//! +//! 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::ffi::{OsStr, OsString}; +use std::path::PathBuf; + +mod implementation { + use super::OsString; + + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/provider_oauth_cli_impl.rs.inc" + )); + + #[cfg(not(coverage))] + pub(super) fn usage_text() -> String { + 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: Vec, + environment_home: Option, + ) -> Result<(), String> { + 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 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; + } + 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!( + "{}", + 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. +/// +/// 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 { + if windows { + user_profile + } else { + home + } +} + +#[cfg(not(coverage))] +fn command_line_args() -> Vec { + std::env::args_os().skip(1).collect() +} + +/// 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) = raw_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 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(windows)] + apply_environment_data_home_default_connections(&mut args, std::env::var_os("APPDATA")); + #[cfg(all(unix, not(target_os = "macos")))] + 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), + 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() {} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c95334c0f..4eaad9b71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,11 @@ pub mod provider_recovery; /// Preserves provider-client running/stopped state across temporary maintenance stops. pub mod provider_runtime_state; 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; 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(()) } diff --git a/src-tauri/src/provider_oauth.rs b/src-tauri/src/provider_oauth.rs index e464a56c5..11638da71 100644 --- a/src-tauri/src/provider_oauth.rs +++ b/src-tauri/src/provider_oauth.rs @@ -1,19 +1,20 @@ -//! 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 _}; 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))] @@ -47,6 +55,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 +67,7 @@ pub struct OAuthConnection { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] struct ConnectionDocument { version: u32, connections: Vec, @@ -313,6 +323,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 @@ -327,19 +347,114 @@ 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(()) +} + +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()) + } + #[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()), + } +} + pub fn load_connections(path: &Path) -> Result, String> { - 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()), + validate_connection_document_parent(connection_document_parent(path), true)?; + 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)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o7777 != 0o600 { + return Err("oauth-connection-document-permissions-unsafe".into()); + } + } 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 @@ -350,9 +465,24 @@ 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) } +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> { if connections.len() > MAX_CONNECTIONS { return Err("oauth-connection-count-invalid".into()); @@ -360,49 +490,29 @@ fn save_connections(path: &Path, connections: &[OAuthConnection]) -> Result<(), for connection in connections { validate_connection(connection)?; } - let parent = path - .parent() - .ok_or_else(|| "oauth-connection-directory-invalid".to_string())?; - std::fs::create_dir_all(parent).map_err(|_| "oauth-connection-directory-unavailable")?; - 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()); - } - } + 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")?; - 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()); - } - #[cfg(windows)] - if path.exists() { - std::fs::remove_file(path).map_err(|_| "oauth-connection-document-replace-failed")?; + if encoded.len() as u64 > MAX_CONNECTION_DOCUMENT_BYTES { + return Err("oauth-connection-document-too-large".into()); } - if std::fs::rename(&temporary, path).is_err() { - let _ = std::fs::remove_file(&temporary); - return Err("oauth-connection-document-replace-failed".into()); + let parent = connection_document_parent(path); + validate_connection_document_parent(parent, true)?; + 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()), } - Ok(()) + write_connection_document_create_new(path, &encoded).map_err(map_connection_create_new_error) } pub fn connection_for_root( @@ -611,7 +721,10 @@ 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())?; stream .set_read_timeout(Some(Duration::from_secs(2))) .map_err(|_| "oauth-callback-read-config-failed".to_string())?; @@ -632,9 +745,12 @@ 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") + 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(); @@ -647,6 +763,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()) } @@ -677,7 +818,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 { @@ -687,7 +832,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) => { @@ -768,7 +913,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()); } @@ -914,6 +1059,60 @@ 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. +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, @@ -924,7 +1123,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 @@ -941,6 +1148,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()); @@ -952,11 +1160,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) } @@ -975,17 +1185,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()); } @@ -994,6 +1226,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::*; @@ -1179,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![ @@ -1275,6 +1521,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")); 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..1498f14b6 --- /dev/null +++ b/src-tauri/tests/cli_help_health_oauth_exit.rs @@ -0,0 +1,113 @@ +#![cfg(feature = "cloud-cli")] + +use std::process::Command; + +fn assert_help_success(binary: &str, flag: &str, expected_usage: &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_eq!( + stdout, + format!("{expected_usage}\n"), + "help output must equal the stable usage synopsis" + ); +} + +fn assert_invalid_argument_is_bounded(binary: &str, arguments: &[&str]) { + let output = Command::new(binary) + // 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"); + + assert!( + !output.status.success(), + "an invalid invocation must remain a non-zero failure" + ); + assert!( + output.stdout.is_empty(), + "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"); + assert!( + !stderr.contains("not-shown"), + "invalid diagnostics must not echo arbitrary argument payloads" + ); +} + +#[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("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" + ); +} + +#[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]\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"]); + assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary); +} + +#[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)\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"]); + assert_invalid_argument_is_bounded(binary, &["--help", "--opaque-option=not-shown"]); + #[cfg(unix)] + assert_non_utf8_argument_is_bounded(binary); +} 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:?}" + ); + } +} 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" + ); +} 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()) + ); + } +} 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..4df77b12b --- /dev/null +++ b/src-tauri/tests/provider_oauth_callback_parser_coverage.rs @@ -0,0 +1,103 @@ +#![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. + +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; +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" + ); +} 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..43fe988a3 --- /dev/null +++ b/src-tauri/tests/provider_oauth_cli_process.rs @@ -0,0 +1,249 @@ +#![cfg(feature = "cloud-cli")] + +//! Black-box regressions for the shipped provider OAuth CLI host/process boundary. +//! +//! 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 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, "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() { + 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() + .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); +} + +#[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(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() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + 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 = run_list(&home, &connections); + + 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" + ); +} + +#[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"); +} \ No newline at end of file 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" + ); +} 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..86d82c902 --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_object_binding_contract.rs @@ -0,0 +1,37 @@ +//! 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"); + let compact_source: String = source + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + + 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!( + compact_source.contains("letmetadata=file.metadata()"), + "regular-file, permission, and size admission must come from the opened object" + ); + assert!( + 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" + ); +} 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..1b8b78d54 --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_write_authority_coverage.rs @@ -0,0 +1,208 @@ +#![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. + +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; +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_first_publication_is_private_and_existing_replacement_fails_closed() { + let temp = tempfile::tempdir().unwrap(); + 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(); + 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 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; + assert_eq!( + 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)] +#[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" + ); +} 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..3c6e734d4 --- /dev/null +++ b/src-tauri/tests/provider_oauth_connection_document_write_bound_regression.rs @@ -0,0 +1,57 @@ +#![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. +#[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 +// shipped cloud types under the same crate-local path while keeping the test credential-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" + ); +} 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" + ); +} 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" + ); +} 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" + ); +} 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..17658a862 --- /dev/null +++ b/src-tauri/tests/provider_oauth_disconnect_all_matching_coverage.rs @@ -0,0 +1,93 @@ +#![allow(dead_code, unused_imports)] + +//! 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/private_directory_publication.rs"] +mod private_directory_publication; +include!("../src/provider_oauth.rs"); + +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\내 드라이브"; + #[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_fails_before_any_credential_delete_when_document_replacement_is_unavailable() { + 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); + + 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, 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()); + Ok(()) + }) + .unwrap_err(); + + 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); +} 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..24552a92e --- /dev/null +++ b/src-tauri/tests/provider_oauth_disconnect_stale_delete_rollback_coverage.rs @@ -0,0 +1,94 @@ +#![allow(dead_code, unused_imports)] + +//! 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/private_directory_publication.rs"] +mod private_directory_publication; +include!("../src/provider_oauth.rs"); + +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\내 드라이브"; + #[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 replacement_refusal_precedes_stale_and_canonical_credential_deletion() { + 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); + + 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, 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()); + Err("provider-oauth-keyring-delete-failed".to_string()) + }) + .unwrap_err(); + + assert_eq!( + error, + "oauth-connection-document-object-bound-replacement-unavailable" + ); + 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); +} 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" + ); +} 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, + ); +} 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..575e8b396 --- /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/private_directory_publication.rs"] +mod private_directory_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" + ); +} 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..96a16847b --- /dev/null +++ b/src-tauri/tests/provider_oauth_leaf_permission_coverage.rs @@ -0,0 +1,35 @@ +//! Unix privacy-boundary coverage for durable OAuth connection metadata. +//! +//! A connection document contains provider, client, scope, and cloud-root identity 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_exact_private_leaf_permissions() { + use disksage_lib::provider_oauth::load_connections; + use std::os::unix::fs::PermissionsExt; + + 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(); + 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 exact private OAuth metadata" + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } +} 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()); +} 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()); +} 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..3e150bd25 --- /dev/null +++ b/src-tauri/tests/provider_oauth_loopback_stream_mode_coverage.rs @@ -0,0 +1,46 @@ +//! 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)] + +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; +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, "127.0.0.1") + .expect("callback reader must own a blocking-with-timeout read boundary"); + assert_eq!(target, "/?code=delayed&state=expected"); + + client.join().expect("loopback client joins"); +} 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); + } +} 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); +} 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" + ); +} 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()); +} 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" + ); +} 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..8f6f4f124 --- /dev/null +++ b/src-tauri/tests/provider_oauth_publication_authority_contract.rs @@ -0,0 +1,46 @@ +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_uses_create_new_owner_and_refuses_existing_replacement() { + let source = provider_oauth_source(); + + assert!( + 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-document-object-bound-replacement-unavailable"), + "existing connection documents must fail closed while exact-source replacement authority is unavailable" + ); + assert!( + source.contains("oauth-connection-document-object-bound-publication-unavailable"), + "platforms without canonical private publication must fail closed without a pathname fallback" + ); + assert!( + !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 [ + "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}" + ); + } +} 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" + ); +} 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..b7d8791df --- /dev/null +++ b/src-tauri/tests/provider_oauth_reauthorization_cleanup_coverage.rs @@ -0,0 +1,245 @@ +#![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 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/private_directory_publication.rs"] +mod private_directory_publication; +include!("../src/provider_oauth.rs"); + +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\내 드라이브"; + #[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 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, + 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_reports_unavailable_retry_publication_and_preserves_canonical_document() { + 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); + 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 before = std::fs::read(&document).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-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] +fn successful_legacy_cleanup_keeps_the_published_document_canonical_only() { + 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); + 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]); +} + +#[test] +fn no_stale_identity_never_calls_the_credential_delete_boundary() { + 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); + 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 = private_tempdir(); + 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" + ); +} + +#[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()); +} 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" + ); +} 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..455c11c21 --- /dev/null +++ b/src-tauri/tests/provider_oauth_token_document_parser_coverage.rs @@ -0,0 +1,207 @@ +#![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. + +#[path = "../src/private_directory_publication.rs"] +mod private_directory_publication; +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 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( + 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"); +} 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" + ); +} 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..0585e058b --- /dev/null +++ b/src-tauri/tests/provider_oauth_windows_atomic_replace_contract.rs @@ -0,0 +1,35 @@ +#![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 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() { + 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()); +} 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..f3ca246e8 --- /dev/null +++ b/src-tauri/tests/provider_oauth_xdg_data_home_process.rs @@ -0,0 +1,187 @@ +#![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 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"); + 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"); + 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); +} \ No newline at end of file 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; diff --git a/src/lib/testWorkflowPathFilterContract.test.ts b/src/lib/testWorkflowPathFilterContract.test.ts new file mode 100644 index 000000000..202856d6e --- /dev/null +++ b/src/lib/testWorkflowPathFilterContract.test.ts @@ -0,0 +1,137 @@ +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"; + +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 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 negatives; +} + +describe("test workflow path-filter contract", () => { + 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 fixture of fixtures) { + expect(negativePathsIgnoreEntries(fixture)).toContain("!docs/example.md"); + } + }); + + 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"); + }); + + 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( + "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. +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 }); + } +}); \ No newline at end of file