diff --git a/apps/buddy/electron/main/app/__tests__/desktopStartupFailure.spec.ts b/apps/buddy/electron/main/app/__tests__/desktopStartupFailure.spec.ts index 4f4980a6b..0bb69053c 100644 --- a/apps/buddy/electron/main/app/__tests__/desktopStartupFailure.spec.ts +++ b/apps/buddy/electron/main/app/__tests__/desktopStartupFailure.spec.ts @@ -5,9 +5,9 @@ import { describeDesktopStartupFailure } from '../desktopStartupFailure' describe('startup failure presentation', () => { it('gives a useful private storage explanation and a diagnostic reference', () => { - const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_UNSAFE', { kind: 'private_directories', operation: 'validate_acl' }) + const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_UNSAFE', { kind: 'private_directories', operation: 'inspect_directory' }) const options = describeDesktopStartupFailure(error, 'zh-CN', 'launch-fixture') - expect(options.detail).toContain('访问权限不符合要求') + expect(options.detail).toContain('路径包含符号链接') expect(options.detail).toContain('诊断编号: launch-fixture') expect(options.buttons).toEqual(['重新检查并启动', '打开日志目录', '退出应用', '导出诊断包', '复制诊断信息']) expect(options.cancelId).toBe(2) @@ -22,13 +22,12 @@ describe('startup failure presentation', () => { expect(options.detail).not.toContain('fixture') }) - it('shows only structured ACL facts and excludes local paths from copied details', () => { - const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_UNSAFE', { kind: 'private_directories', operation: 'validate_acl', directoryRole: 'user_data', acl: { reason: 'untrusted_access', principal: 'other', accessMask: 0x1200A9, aceFlags: 0x13 } }) + it('shows preparation failures and excludes local paths from copied details', () => { + const error = new PrivateDirectoryError('PRIVATE_DIRECTORIES_FAILED', { kind: 'private_directories', operation: 'open_directory', directoryRole: 'lexora_home', systemError: { domain: 'ntstatus', code: 0xC0000022 } }) const options = describeDesktopStartupFailure(error, 'zh-CN', 'launch-fixture', 'C:\\Users\\private-user\\AppData', true, 'C:\\Users\\private-user\\logs') - expect(options.recovery.reason).toContain('并不一定代表目录不安全') + expect(options.recovery.reason).toContain('无法准备应用数据目录') expect(options.recovery.fields).toContainEqual(expect.objectContaining({ label: '本机日志目录', localOnly: true })) - expect(options.recovery.copyDetails).toContain('mask=0x1200a9') - expect(options.recovery.copyDetails).toContain('flags=0x13') + expect(options.recovery.copyDetails).toContain('open_directory / lexora_home') expect(options.recovery.copyDetails).not.toContain('private-user') }) }) diff --git a/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts b/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts index ee4e8b257..04148af03 100644 --- a/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts +++ b/apps/buddy/electron/main/app/__tests__/windowsDesktopStorage.native.spec.ts @@ -7,13 +7,12 @@ import { fileURLToPath } from 'node:url' import { afterEach, beforeAll, beforeEach, describe, it } from 'vitest' import { inspectWindowsPrivateDirectory as inspect } from '../../../../platform/filesystem/__tests__/windowsPrivateDirectoryFixture' import { resolveBuddyPrivateDirectories } from '../../../../platform/native/nativeHost' -import { PrivateDirectoryError } from '../../../../platform/windows/privateDirectories' import { readDiagnosticError } from '../../../../shared/diagnostics/applicationDiagnostic' import { checkDesktopDirectories, prepareDesktopPrivateStorage } from '../desktopStorage' const privateAcl = 'D:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)' const additionalPrincipal = 'S-1-5-21-111111111-222222222-333333333-1001' -const inheritedAcl = `${privateAcl}(A;OICI;0x1200a9;;;${additionalPrincipal})` +const inheritedAcl = `${privateAcl}(A;OICI;FA;;;${additionalPrincipal})` describe.skipIf(process.platform !== 'win32')('windows desktop storage', () => { let helper: string @@ -31,7 +30,7 @@ describe.skipIf(process.platform !== 'win32')('windows desktop storage', () => { await rm(root, { recursive: true }) }) - it('preserves inherited read-execute grants, files and ACLs across repeated startup checks', async () => { + it('preserves inherited access grants, files and ACLs across repeated startup checks', async () => { const parent = join(root, 'inherited-parent') await mkdir(parent) const parentSecurity = inspect(parent, inheritedAcl) @@ -98,44 +97,7 @@ describe.skipIf(process.platform !== 'win32')('windows desktop storage', () => { } }, 60_000) - it('accepts read-execute grants added to existing product storage without ACL repair', async () => { - const home = join(root, 'private-product-home') - prepareDesktopPrivateStorage(home, helper) - const sentinel = join(home, 'preserved.txt') - await writeFile(sentinel, 'existing product data') - const before = inspect(home, inheritedAcl) - for (let launch = 0; launch < 2; launch++) { - prepareDesktopPrivateStorage(home, helper) - await checkDesktopDirectories({ lexora_home: home }, helper) - } - assert.equal(inspect(home).sddl, before.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') - }, 60_000) - - it('rejects read-execute plus write before loading without ACL repair or diagnostic identity disclosure', async () => { - const home = join(root, 'private-product-home') - prepareDesktopPrivateStorage(home, helper) - const sentinel = join(home, 'preserved.txt') - await writeFile(sentinel, 'existing product data') - const before = inspect(home, `${privateAcl}(A;OICI;0x1200ab;;;${additionalPrincipal})`) - const isPrivateFailure = (error: unknown) => { - assert.ok(error instanceof PrivateDirectoryError) - assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') - assert.equal(error.failure.directoryRole, 'lexora_home') - assert.equal(error.failure.acl?.accessMask, 0x1200AB) - const diagnostic = JSON.stringify(readDiagnosticError(error)) - assert.ok(!diagnostic.includes(additionalPrincipal)) - assert.ok(!diagnostic.includes(root)) - return true - } - assert.throws(() => prepareDesktopPrivateStorage(home, helper), isPrivateFailure) - await assert.rejects(checkDesktopDirectories({ lexora_home: home, user_data: join(root, 'not-created') }, helper), isPrivateFailure) - assert.ok(!(await readdir(root)).includes('not-created')) - assert.equal(inspect(home).sddl, before.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing product data') - }, 60_000) - - it('reports actual file-creation denial without overwriting existing runtime data', async () => { + it.each(['lexora_home', 'user_data'] as const)('reports actual file-creation denial in %s without overwriting existing data', async (role) => { const userData = join(root, 'electron') await mkdir(userData) const sentinel = join(userData, 'preserved.txt') @@ -143,14 +105,14 @@ describe.skipIf(process.platform !== 'win32')('windows desktop storage', () => { const before = inspect(userData) const denied = inspect(userData, 'D:P(D;;0x2;;;CURRENT)(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') try { - await assert.rejects(checkDesktopDirectories({ user_data: userData }), (error: unknown) => { + await assert.rejects(checkDesktopDirectories({ [role]: userData }, helper), (error: unknown) => { const diagnostic = readDiagnosticError(error) assert.equal(diagnostic.errorCode, 'DESKTOP_BOOTSTRAP_FAILED') assert.equal(diagnostic.failure?.kind, 'desktop_bootstrap') if (diagnostic.failure?.kind !== 'desktop_bootstrap') return false assert.equal(diagnostic.failure.operation, 'probe_directory') - assert.equal(diagnostic.failure.directoryRole, 'user_data') + assert.equal(diagnostic.failure.directoryRole, role) assert.ok(['EACCES', 'EPERM'].includes(diagnostic.failure.systemCode ?? '')) return true }) diff --git a/apps/buddy/electron/main/app/desktopStartupFailure.ts b/apps/buddy/electron/main/app/desktopStartupFailure.ts index 5e787c461..d8d39160f 100644 --- a/apps/buddy/electron/main/app/desktopStartupFailure.ts +++ b/apps/buddy/electron/main/app/desktopStartupFailure.ts @@ -22,24 +22,20 @@ export function resolveStartupFailureDirectory(error: unknown, paths: BuddyRunti export function describeDesktopStartupFailure(error: unknown, language: LexoraConfig['desktop']['language'], launchId?: string, directory?: string, logsAvailable = true, logsPath?: string): RecoveryPresentation { const { errorCode: code = 'OPERATION_FAILED', failure } = readDiagnosticError(error) - const unconfirmedPermissions = error instanceof PrivateDirectoryError && (error.failure.acl?.reason === 'unsupported_ace' || error.failure.acl?.principal === 'other') const reason = error instanceof PowerShellUnavailableError ? 'powerShellUnavailable' : code === 'NETWORK_START_FAILED' ? 'networkStartupFailed' - : unconfirmedPermissions - ? 'privateDirectoriesUnconfirmed' - : code === 'PRIVATE_DIRECTORIES_UNSAFE' - ? 'privateDirectoriesUnsafe' - : code.startsWith('PRIVATE_DIRECTORIES_') - ? 'privateDirectoriesFailed' - : 'startupFailureHelp' + : code === 'PRIVATE_DIRECTORIES_UNSAFE' + ? 'privateDirectoriesUnsafe' + : code.startsWith('PRIVATE_DIRECTORIES_') + ? 'privateDirectoriesFailed' + : 'startupFailureHelp' const t = (key: Parameters[1]) => translateDesktopNative(language, key) const fields: RecoveryPresentation['recovery']['fields'] = [ { label: t('startupErrorCode'), value: code }, ...(launchId ? [{ label: t('diagnosticReference'), value: launchId }] : []), ...(failure ? [{ label: t('startupFailureStage'), value: [failure.operation, failure.kind === 'network_startup' ? undefined : failure.directoryRole, failure.kind === 'private_directories' ? undefined : failure.systemCode].filter(Boolean).join(' / ') }] : []), - ...(failure?.kind === 'private_directories' && failure.acl ? [{ label: t('startupPermissionCheck'), value: [failure.acl.reason, failure.acl.principal, failure.acl.accessMask === undefined ? undefined : `mask=0x${failure.acl.accessMask.toString(16)}`, failure.acl.aceFlags === undefined ? undefined : `flags=0x${failure.acl.aceFlags.toString(16)}`].filter(Boolean).join(' / ') }] : []), ...(directory ? [{ label: t('affectedDirectory'), value: directory, localOnly: true }] : []), ...(logsPath ? [{ label: t('startupLogsPath'), value: logsPath, localOnly: true }] : []), ] diff --git a/apps/buddy/electron/main/desktopNativeI18n.ts b/apps/buddy/electron/main/desktopNativeI18n.ts index db9718f29..18b7483b8 100644 --- a/apps/buddy/electron/main/desktopNativeI18n.ts +++ b/apps/buddy/electron/main/desktopNativeI18n.ts @@ -11,12 +11,11 @@ const messages = { startupFailed: 'Lexora Buddy could not start', startupFailureHelp: 'Open the logs folder to view the diagnostic information.', networkStartupFailed: 'The application network could not be initialized. Check the failed step and system error code in the diagnostic bundle.', - privateDirectoriesUnsafe: 'An application data directory does not meet the required access permissions or points to another location. Check its permissions and location, then try again.', + privateDirectoriesUnsafe: 'An application data path contains a symbolic link, junction or non-directory object. Use a regular directory, then try again.', privateDirectoriesFailed: 'Lexora Buddy could not prepare its application data directories. Check the diagnostic information for the failed operation.', - privateDirectoriesUnconfirmed: 'Lexora Buddy cannot confirm the access permissions of this data directory. This does not necessarily mean the directory is unsafe. Keep the diagnostic reference for support; do not remove unfamiliar Windows permissions.', startupRecoveryTitle: 'Let’s get Lexora Buddy running', startupRecoveryIsolation: 'This recovery window does not load your conversations, credentials or Agent. No data has been reset.', - startupRechecking: 'Checking data directory permissions and access…', + startupRechecking: 'Checking data directory paths and access…', startupRecheckFailed: 'The data directory check still fails. No restart was attempted. Please keep the diagnostic reference for support.', openLogs: 'Open logs folder', openAffectedDirectory: 'Locate affected folder', @@ -26,7 +25,6 @@ const messages = { directoryOpenFailed: 'The location could not be opened. Check that it exists and that your account can access it.', diagnosticReference: 'Diagnostic reference', startupErrorCode: 'Error code', - startupPermissionCheck: 'Permission check', startupLogsPath: 'Local logs folder', exportDiagnostics: 'Export diagnostic bundle', copyDiagnosticDetails: 'Copy diagnostic details', @@ -65,12 +63,11 @@ const messages = { startupFailed: 'Lexora Buddy 启动失败', startupFailureHelp: '可以打开日志目录查看诊断信息。', networkStartupFailed: '应用网络初始化失败,请查看诊断包中的失败步骤与系统错误码。', - privateDirectoriesUnsafe: '应用数据目录的访问权限不符合要求,或目录指向了其他位置。请检查目录权限和位置后重试。', + privateDirectoriesUnsafe: '应用数据路径包含符号链接、目录连接或非目录对象。请使用普通目录后重试。', privateDirectoriesFailed: '无法准备应用数据目录,请查看诊断信息中的失败操作。', - privateDirectoriesUnconfirmed: '暂时无法确认此数据目录的访问权限,并不一定代表目录不安全。请保留诊断编号以便排查,不要自行删除不认识的 Windows 权限。', startupRecoveryTitle: '一起解决启动问题', startupRecoveryIsolation: '此恢复窗口不加载对话、凭据或 Agent。您的数据没有被重置。', - startupRechecking: '正在检查数据目录权限和读写能力…', + startupRechecking: '正在检查数据目录路径和读写能力…', startupRecheckFailed: '数据目录检查仍未通过,没有发起重启。请保留诊断编号以便排查。', openLogs: '打开日志目录', openAffectedDirectory: '定位问题目录', @@ -80,7 +77,6 @@ const messages = { directoryOpenFailed: '无法打开该位置,请检查目录是否存在,以及当前账户是否有权访问。', diagnosticReference: '诊断编号', startupErrorCode: '错误码', - startupPermissionCheck: '权限检查结果', startupLogsPath: '本机日志目录', exportDiagnostics: '导出诊断包', copyDiagnosticDetails: '复制诊断信息', diff --git a/apps/buddy/native/host/__tests__/private_directories_windows.rs b/apps/buddy/native/host/__tests__/private_directories_windows.rs index dbef2e159..1c4fcec4f 100644 --- a/apps/buddy/native/host/__tests__/private_directories_windows.rs +++ b/apps/buddy/native/host/__tests__/private_directories_windows.rs @@ -33,9 +33,9 @@ fn creates_nested_unicode_directories_and_reuses_them_without_replacing_files() let fixture = Fixture::new(); let path = fixture.0.join("示例").join("private"); let paths = [path.to_str().unwrap().to_owned()]; - ensure(&paths).unwrap(); + super::super::ensure(&paths).unwrap(); fs::write(path.join("preserved.txt"), "fixture").unwrap(); - ensure(&paths).unwrap(); + super::super::ensure(&paths).unwrap(); assert_eq!( fs::read_to_string(path.join("preserved.txt")).unwrap(), "fixture" @@ -47,7 +47,7 @@ fn ordinary_files_cannot_be_used_as_private_directories() { let fixture = Fixture::new(); let path = fixture.0.join("file"); fs::write(&path, "fixture").unwrap(); - let failure = ensure(&[path.to_str().unwrap().to_owned()]).unwrap_err(); + let failure = super::super::ensure(&[path.to_str().unwrap().to_owned()]).unwrap_err(); assert_eq!(failure.operation, DirectoryOperation::OpenDirectory); assert_eq!(failure.directory_index, Some(0)); assert!(failure.system_error.is_some()); diff --git a/apps/buddy/native/host/__tests__/private_security_windows.rs b/apps/buddy/native/host/__tests__/private_security_windows.rs index 63aae431c..1a92c009d 100644 --- a/apps/buddy/native/host/__tests__/private_security_windows.rs +++ b/apps/buddy/native/host/__tests__/private_security_windows.rs @@ -1,5 +1,76 @@ use super::*; +fn set_directory_acl(path: &std::path::Path, descriptor: &LocalMemory) { + use windows_sys::Win32::Security::{PROTECTED_DACL_SECURITY_INFORMATION, SetFileSecurityW}; + + let name: Vec = path.to_str().unwrap().encode_utf16().chain([0]).collect(); + assert_ne!( + // SAFETY: The NUL-terminated path and owned security descriptor remain live for this call. + unsafe { + SetFileSecurityW( + name.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor.0, + ) + }, + 0, + "{}", + std::io::Error::last_os_error() + ); +} + +#[test] +fn application_storage_preserves_access_while_sandbox_storage_requires_private_permissions() { + let fixture = tempfile::tempdir().unwrap(); + let path = fixture.path().join("existing"); + std::fs::create_dir(&path).unwrap(); + set_directory_acl(&path, &from_sddl("D:P(A;OICI;FA;;;WD)").unwrap()); + let sentinel = path.join("preserved.txt"); + std::fs::write(&sentinel, "existing data").unwrap(); + let paths = [path.to_str().unwrap().to_owned()]; + let request = serde_json::to_vec(&serde_json::json!({ "paths": paths })).unwrap(); + let mut output = Vec::new(); + crate::private_directories::run(request.as_slice(), &mut output).unwrap(); + assert_eq!(output, b"{\"ok\":true}"); + let failure = crate::private_directories::ensure(&paths).unwrap_err(); + assert_eq!(failure.operation, DirectoryOperation::ValidateAcl); + assert_eq!( + failure.acl.unwrap().principal, + Some(DirectoryPrincipal::Everyone) + ); + assert_eq!(std::fs::read_to_string(sentinel).unwrap(), "existing data"); +} + +#[test] +fn application_storage_does_not_require_directory_listing_or_acl_reads() { + let fixture = tempfile::tempdir().unwrap(); + let parent = fixture.path().join("parent"); + let child = parent.join("child"); + crate::private_directories::ensure(&[child.to_str().unwrap().to_owned()]).unwrap(); + let sentinel = child.join("preserved.txt"); + std::fs::write(&sentinel, "existing data").unwrap(); + let paths = [ + parent.to_str().unwrap().to_owned(), + child.to_str().unwrap().to_owned(), + ]; + let request = serde_json::to_vec(&serde_json::json!({ "paths": paths })).unwrap(); + let restore = PrivateSecurity::new().unwrap(); + let denied = from_sddl("D:P(D;;0x20000;;;OW)(D;;0x1;;;WD)(A;OICI;FA;;;WD)").unwrap(); + set_directory_acl(&parent, &denied); + let mut output = Vec::new(); + let application = crate::private_directories::run(request.as_slice(), &mut output); + let sandbox = crate::private_directories::ensure(&paths); + let preserved = std::fs::read_to_string(&sentinel); + set_directory_acl(&parent, &restore.descriptor); + application.unwrap(); + assert_eq!(output, b"{\"ok\":true}"); + assert_eq!( + sandbox.unwrap_err().operation, + DirectoryOperation::OpenDirectory + ); + assert_eq!(preserved.unwrap(), "existing data"); +} + #[test] fn freshly_created_policy_has_private_owner_and_inheritable_acl() { let security = PrivateSecurity::new().unwrap(); diff --git a/apps/buddy/native/host/src/private_directories.rs b/apps/buddy/native/host/src/private_directories.rs index 707d2a625..b7963e5ba 100644 --- a/apps/buddy/native/host/src/private_directories.rs +++ b/apps/buddy/native/host/src/private_directories.rs @@ -219,7 +219,7 @@ pub fn run(input: impl Read, output: impl Write) -> Result<(), DirectoryFailure> .map_err(|code| DirectoryFailure::new(code, DirectoryOperation::Request))?; #[cfg(windows)] { - windows::ensure(&request.paths)?; + windows::ensure(&request.paths, windows::ExistingDirectoryPolicy::Preserve)?; let mut output = output; output.write_all(b"{\"ok\":true}").map_err(|_| { DirectoryFailure::new(DirectoryError::Failed, DirectoryOperation::Response) @@ -237,7 +237,7 @@ pub fn run(input: impl Read, output: impl Write) -> Result<(), DirectoryFailure> #[cfg(windows)] pub(crate) fn ensure(paths: &[String]) -> Result<(), DirectoryFailure> { - windows::ensure(paths) + windows::ensure(paths, windows::ExistingDirectoryPolicy::ValidateAcl) } #[cfg(test)] diff --git a/apps/buddy/native/host/src/private_directories/windows.rs b/apps/buddy/native/host/src/private_directories/windows.rs index 36f4138b5..f92ab2564 100644 --- a/apps/buddy/native/host/src/private_directories/windows.rs +++ b/apps/buddy/native/host/src/private_directories/windows.rs @@ -34,7 +34,16 @@ use super::{ mod security; use security::PrivateSecurity; -pub(super) fn ensure(paths: &[String]) -> Result<(), DirectoryFailure> { +#[derive(PartialEq, Eq)] +pub(super) enum ExistingDirectoryPolicy { + Preserve, + ValidateAcl, +} + +pub(super) fn ensure( + paths: &[String], + existing: ExistingDirectoryPolicy, +) -> Result<(), DirectoryFailure> { let security = PrivateSecurity::new()?; for (directory_index, path) in paths.iter().enumerate() { let (root, parts) = directory_parts(path).map_err(|code| { @@ -47,10 +56,11 @@ pub(super) fn ensure(paths: &[String]) -> Result<(), DirectoryFailure> { DirectoryFailure::new(DirectoryError::Failed, DirectoryOperation::OpenDirectory) .at_directory(directory_index) })?; - let private = index + 1 == parts.len(); + let private = + existing == ExistingDirectoryPolicy::ValidateAcl && index + 1 == parts.len(); let (child, created) = open_child(parent, part, &security, private) .map_err(|error| error.at_directory(directory_index))?; - if created && !private { + if existing == ExistingDirectoryPolicy::ValidateAcl && created && !private { let (checked, _) = open_child(parent, part, &security, true) .map_err(|error| error.at_directory(directory_index))?; security diff --git a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts index 1a9db1d20..dc793cb3e 100644 --- a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts +++ b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectories.native.spec.ts @@ -8,7 +8,7 @@ import { afterEach, beforeAll, beforeEach, describe, it } from 'vitest' import { resolveBuddyPrivateDirectories } from '../../native/nativeHost' import { PrivateDirectoryError } from '../../windows/privateDirectories' import { ensurePrivateDirectories } from '../privateDirectories' -import { inspectWindowsPrivateDirectory as inspect, setWindowsPrivateDirectoryAcl } from './windowsPrivateDirectoryFixture' +import { inspectWindowsPrivateDirectory as inspect } from './windowsPrivateDirectoryFixture' async function missing(path: string) { await assert.rejects(access(path), { code: 'ENOENT' }) @@ -70,23 +70,15 @@ describe.skipIf(process.platform !== 'win32')('windows private directories', () assert.ok(!inspect(preserved).allows.includes('S-1-3-0')) }, 60_000) - it('accepts existing metadata, read and execute grants without changing directory or file ACLs', async () => { - for (const [name, grant] of [ - ['users-attributes', '(A;;0x80;;;BU)'], - ['everyone-metadata', '(A;OICI;0x120080;;;WD)'], - ['app-packages-metadata', '(A;OICIIO;0x120080;;;AC)'], - ['everyone-traverse', '(A;;0x20;;;WD)'], - ['directory-traverse', '(A;CI;0x1200a0;;;BU)'], - ['file-execute-inheritance', '(A;OICI;0x20;;;WD)'], - ['attributes-and-content', '(A;;0x81;;;BU)'], - ['everyone-read', '(A;OICI;FR;;;WD)'], - ['everyone-read-execute', '(A;;0x1200a9;;;WD)'], - ['other-read-execute', '(A;OICI;0x1200a9;;;S-1-5-21-1-2-3-1001)'], - ['capability-read-execute', '(A;OICI;0x1200a9;;;S-1-15-3-1024-1-2-3-4-5-6-7-8)'], + it('preserves existing access grants and data without rewriting directory or file ACLs', async () => { + for (const [name, sddl] of [ + ['managed-write', 'D:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;S-1-5-21-1-2-3-1001)'], + ['everyone-write', 'D:P(A;OICI;FA;;;WD)'], + ['null-dacl', 'D:NO_ACCESS_CONTROL'], ]) { const path = join(directory, name!) await ensurePrivateDirectories([path], helper) - const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) + const before = inspect(path, sddl) const sentinel = join(path, 'preserved.txt') await writeFile(sentinel, 'existing-user-data') const sentinelSecurity = inspect(sentinel) @@ -98,60 +90,6 @@ describe.skipIf(process.platform !== 'win32')('windows private directories', () } }, 60_000) - it('rejects write, delete, owner and DACL changes, including inherit-only grants, without modifying data', async () => { - for (const [name, grant] of [ - ['unknown-capability', '(A;OICI;FA;;;S-1-15-3-1024-1-2-3-4-5-6-7-8)'], - ['read-and-write', '(A;OICI;0x1200ab;;;WD)'], - ['read-and-append', '(A;OICI;0x1200ad;;;WD)'], - ['read-and-delete', '(A;OICI;0x1300a9;;;WD)'], - ['read-and-delete-child', '(A;OICI;0x1200e9;;;WD)'], - ['read-and-write-dacl', '(A;OICI;0x1600a9;;;WD)'], - ['read-and-write-owner', '(A;OICI;0x1a00a9;;;WD)'], - ['inherit-only-write', '(A;OICIIO;0x1200ab;;;WD)'], - ]) { - const path = join(directory, name!) - await ensurePrivateDirectories([path], helper) - const sentinel = join(path, 'preserved.txt') - await writeFile(sentinel, 'existing-user-data') - const before = inspect(path, `O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)${grant}`) - await assert.rejects(ensurePrivateDirectories([path], helper), { code: 'PRIVATE_DIRECTORIES_UNSAFE' }) - assert.equal(inspect(path).sddl, before.sddl) - assert.equal(await readFile(sentinel, 'utf8'), 'existing-user-data') - } - }, 60_000) - - it('validates private children without listing or reading the ACL of existing parents', async () => { - const restrictedParent = join(directory, 'restricted-parent') - const accessibleChild = join(restrictedParent, 'private') - await ensurePrivateDirectories([accessibleChild], helper) - const parentBefore = inspect(restrictedParent, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)') - try { - setWindowsPrivateDirectoryAcl(restrictedParent, 'O:CURRENTD:P(D;;0x20000;;;OW)(D;;0x1;;;CURRENT)(A;;FA;;;CURRENT)(A;;FA;;;SY)(A;;FA;;;BA)') - await assert.rejects(ensurePrivateDirectories([restrictedParent], helper), (error: unknown) => error instanceof PrivateDirectoryError && error.failure.operation === 'open_directory' && error.failure.systemError?.code === 0xC0000022) - await ensurePrivateDirectories([accessibleChild], helper) - } - finally { - setWindowsPrivateDirectoryAcl(restrictedParent, parentBefore.sddl) - assert.equal(inspect(restrictedParent).sddl.replace('D:PAI', 'D:P'), parentBefore.sddl.replace('D:PAI', 'D:P')) - } - }, 60_000) - - it('reports structured write-grant failures and rejects NULL DACL without repair', async () => { - const insecure = join(directory, 'insecure') - await ensurePrivateDirectories([insecure], helper) - const broad = inspect(insecure, 'O:CURRENTD:P(A;OICI;FA;;;CURRENT)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200ab;;;WD)') - await assert.rejects(ensurePrivateDirectories([insecure], helper), (error: unknown) => { - assert.ok(error instanceof PrivateDirectoryError) - assert.equal(error.code, 'PRIVATE_DIRECTORIES_UNSAFE') - assert.deepEqual(error.failure, { kind: 'private_directories', operation: 'validate_acl', directoryIndex: 0, exitCode: 1, acl: { reason: 'untrusted_access', aceIndex: broad.allows.indexOf('S-1-1-0'), aceType: 0, aceFlags: 3, accessMask: 0x1200AB, principal: 'everyone' } }) - return true - }) - assert.equal(inspect(insecure).sddl, broad.sddl) - const nullDacl = inspect(insecure, 'O:CURRENTD:NO_ACCESS_CONTROL') - await assert.rejects(ensurePrivateDirectories([insecure], helper)) - assert.equal(inspect(insecure).sddl, nullDacl.sddl) - }, 60_000) - it('rejects leaf and ancestor junctions without touching their destination', async () => { const target = join(directory, 'junction-target') const junction = join(directory, 'junction') diff --git a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoryFixture.ts b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoryFixture.ts index ccce7897b..a5b6b3fe8 100644 --- a/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoryFixture.ts +++ b/apps/buddy/platform/filesystem/__tests__/windowsPrivateDirectoryFixture.ts @@ -2,14 +2,6 @@ import { Buffer } from 'node:buffer' import { execFileSync } from 'node:child_process' export function inspectWindowsPrivateDirectory(path: string, sddl?: string): { sddl: string, owner: string, protected: boolean, user: string, allows: string[], inherited: boolean[] } { - return runFixture(path, sddl, false) -} - -export function setWindowsPrivateDirectoryAcl(path: string, sddl: string): void { - runFixture(path, sddl, true) -} - -function runFixture(path: string, sddl: string | undefined, writeOnly: boolean) { const script = ` $ErrorActionPreference='Stop' [Console]::InputEncoding=[Text.UTF8Encoding]::new($false) @@ -20,10 +12,6 @@ if($request.sddl){ $security=[Security.AccessControl.DirectorySecurity]::new() $security.SetSecurityDescriptorSddlForm($request.sddl.Replace('CURRENT',$user),[Security.AccessControl.AccessControlSections]::Access) [IO.FileSystemAclExtensions]::SetAccessControl([IO.DirectoryInfo]::new($request.path),$security) - if($request.writeOnly){ - '{}' - exit - } } $acl=Get-Acl -LiteralPath $request.path $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier]) | Where-Object AccessControlType -EQ 'Allow') @@ -37,5 +25,5 @@ $rules=@($acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier] 'Text', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64'), - ], { input: JSON.stringify({ path, sddl, writeOnly }), encoding: 'utf8', windowsHide: true, timeout: 15_000 })) + ], { input: JSON.stringify({ path, sddl }), encoding: 'utf8', windowsHide: true, timeout: 15_000 })) } diff --git a/apps/buddy/platform/windows/privateDirectories.ts b/apps/buddy/platform/windows/privateDirectories.ts index 3f1729c9c..11c678641 100644 --- a/apps/buddy/platform/windows/privateDirectories.ts +++ b/apps/buddy/platform/windows/privateDirectories.ts @@ -21,7 +21,7 @@ export class PrivateDirectoryError extends Error { failure: PrivateDirectoryFailure, options?: ErrorOptions, ) { - super('Buddy private storage must prevent other users from reading or changing application data', options) + super('Buddy application data directories could not be prepared', options) this.name = 'PrivateDirectoryError' this.code = code this.failure = failure