Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -98,59 +97,22 @@ 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')
await writeFile(sentinel, 'existing runtime data')
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
})
Expand Down
14 changes: 5 additions & 9 deletions apps/buddy/electron/main/app/desktopStartupFailure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof translateDesktopNative>[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 }] : []),
]
Expand Down
12 changes: 4 additions & 8 deletions apps/buddy/electron/main/desktopNativeI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -65,12 +63,11 @@ const messages = {
startupFailed: 'Lexora Buddy 启动失败',
startupFailureHelp: '可以打开日志目录查看诊断信息。',
networkStartupFailed: '应用网络初始化失败,请查看诊断包中的失败步骤与系统错误码。',
privateDirectoriesUnsafe: '应用数据目录的访问权限不符合要求,或目录指向了其他位置。请检查目录权限和位置后重试。',
privateDirectoriesUnsafe: '应用数据路径包含符号链接、目录连接或非目录对象。请使用普通目录后重试。',
privateDirectoriesFailed: '无法准备应用数据目录,请查看诊断信息中的失败操作。',
privateDirectoriesUnconfirmed: '暂时无法确认此数据目录的访问权限,并不一定代表目录不安全。请保留诊断编号以便排查,不要自行删除不认识的 Windows 权限。',
startupRecoveryTitle: '一起解决启动问题',
startupRecoveryIsolation: '此恢复窗口不加载对话、凭据或 Agent。您的数据没有被重置。',
startupRechecking: '正在检查数据目录权限和读写能力…',
startupRechecking: '正在检查数据目录路径和读写能力…',
startupRecheckFailed: '数据目录检查仍未通过,没有发起重启。请保留诊断编号以便排查。',
openLogs: '打开日志目录',
openAffectedDirectory: '定位问题目录',
Expand All @@ -80,7 +77,6 @@ const messages = {
directoryOpenFailed: '无法打开该位置,请检查目录是否存在,以及当前账户是否有权访问。',
diagnosticReference: '诊断编号',
startupErrorCode: '错误码',
startupPermissionCheck: '权限检查结果',
startupLogsPath: '本机日志目录',
exportDiagnostics: '导出诊断包',
copyDiagnosticDetails: '复制诊断信息',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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());
Expand Down
71 changes: 71 additions & 0 deletions apps/buddy/native/host/__tests__/private_security_windows.rs
Original file line number Diff line number Diff line change
@@ -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<u16> = 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();
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/native/host/src/private_directories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)]
Expand Down
Loading
Loading