feat(desktop): 增加原生 Vault 写入与属性导入
This commit is contained in:
@@ -1,10 +1,52 @@
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
_vault_locks = WeakKeyDictionary()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def web_vault_ownership():
|
||||
"""与 Rust fs2 使用同一 OS 文件锁,避免首次切换时两套写入者重叠。"""
|
||||
from app.config import get_settings
|
||||
from app.errors import ApiError
|
||||
root = get_settings().vault_path
|
||||
managed = root / '.ainote'
|
||||
if managed.is_symlink() or (hasattr(managed, 'is_junction') and managed.is_junction()):
|
||||
raise ApiError(403, 'WORKSPACE_UNSAFE_PATH', '工作区元数据路径不安全')
|
||||
managed.mkdir(parents=True, exist_ok=True)
|
||||
path = managed / 'host.lock'
|
||||
if path.is_symlink():
|
||||
raise ApiError(403, 'WORKSPACE_UNSAFE_PATH', '工作区锁路径不安全')
|
||||
with path.open('a+b') as stream:
|
||||
import os
|
||||
locked = False
|
||||
try:
|
||||
stream.seek(0)
|
||||
try:
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
locked = True
|
||||
except OSError:
|
||||
raise ApiError(409, 'WORKSPACE_OWNER_BUSY', '工作区由其他进程持有,请稍后重试') from None
|
||||
# 桌面元数据已建立后必须经 Host 写入;不以进程退出自动降回 Web 所有权。
|
||||
if (managed / 'host.sqlite3').exists():
|
||||
raise ApiError(409, 'WORKSPACE_OWNER_DESKTOP', '该 Vault 已由桌面 Host 管理,Web 禁止写入')
|
||||
yield
|
||||
finally:
|
||||
if locked:
|
||||
stream.seek(0)
|
||||
if os.name == 'nt':
|
||||
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def vault_mutation_lock():
|
||||
# Service/test lifecycle restarts must not reuse a lock bound to a closed loop.
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -17,6 +59,7 @@ def serialized_vault_mutation(operation):
|
||||
@wraps(operation)
|
||||
async def wrapped(*args, **kwargs):
|
||||
async with vault_mutation_lock():
|
||||
return await operation(*args, **kwargs)
|
||||
with web_vault_ownership():
|
||||
return await operation(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""单写入者门禁使用受控目录,拒绝 Web 绕过已迁移的桌面 Vault。"""
|
||||
|
||||
import pytest
|
||||
from app.config import get_settings
|
||||
from app.errors import ApiError
|
||||
from app.services.coordination import web_vault_ownership
|
||||
|
||||
|
||||
def test_web_refuses_desktop_owned_vault():
|
||||
root = get_settings().vault_path
|
||||
(root / '.ainote').mkdir(parents=True)
|
||||
(root / '.ainote' / 'host.sqlite3').write_bytes(b'fixture-marker')
|
||||
with pytest.raises(ApiError) as error:
|
||||
with web_vault_ownership():
|
||||
pytest.fail('不应取得桌面写入权')
|
||||
assert error.value.code == 'WORKSPACE_OWNER_DESKTOP'
|
||||
|
||||
|
||||
def test_web_lock_is_exclusive_and_released():
|
||||
with web_vault_ownership():
|
||||
with pytest.raises(ApiError):
|
||||
with web_vault_ownership():
|
||||
pytest.fail('不应同时持有锁')
|
||||
with web_vault_ownership():
|
||||
pass
|
||||
Reference in New Issue
Block a user