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
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
- **激活(Activation)**:将某条本地持有的剪贴板内容推进为活动剪贴板状态的动作。
- **收敛(Convergence)**:成员交换活动剪贴板状态后,依据既定顺序规则得到一致状态的过程。
- **重同步(Resync)**:成员重新在线后,将当前活动剪贴板状态重新发送给该成员以促进收敛的动作。它只处理当前活动状态,不补送历史条目。
- **离线投递恢复(Offline Delivery Recovery)**:自动同步开启时,本机内容先立即投递;既有设备暂时不可达时,每台设备只保留最新一条本机内容作为自动补送目标。新的本机复制会替代该设备更早的待送内容;待其恢复在线或应用重启后重新发现在线时,只自动补送这一条。它不向新设备补送旧内容,不处理远端来源,不重复发送已送达或已被替代的内容;自动同步关闭或内容无法恢复时停止自动补送。
- **同步总开关(Global Sync)**:用户对同步的总许可。关闭后,不接收内容、不发送内容或文件,也不执行自动补送、手动重发和恢复广播。
- **自动同步(Automatic Sync)**:同步总开关开启时,对本机新内容自动发送和离线自动补送的选择。关闭后,用户仍可手动重发允许发送的内容或文件。
- **离线投递恢复(Offline Delivery Recovery)**:自动同步开启时,本机内容先立即投递;既有设备暂时不可达时,每台设备只保留最新一条本机内容作为自动补送目标。新的本机复制会替代该设备更早的待送内容;待其恢复在线或应用重启后重新发现在线时,只自动补送这一条。它不向新设备补送旧内容,不处理远端来源,不重复发送已送达或已被替代的内容;自动同步关闭、同步总开关关闭或内容无法恢复时停止自动补送。
- **已被替代(Superseded)**:同一设备有更新的本机内容后,更早的暂时未送达内容进入的最终状态。它不会再自动补送,也不表示发送失败。
- **历史(History)**:已持久化的剪贴板条目及其检索、资源和投递状态集合。
- **普通远端接收(Interactive Receive)**:收到远端内容后保存到历史,并写入本机剪贴板的完整接收方式。
Expand Down
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ members = [
resolver = "2"

[workspace.package]
version = "1.0.0-rc.7"
version = "1.0.0-rc.8"
license = "Apache-2.0"

[workspace.lints.clippy]
Expand Down
4 changes: 4 additions & 0 deletions bindings/uc-engine-uniffi/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ pub enum ResendEntryOutcome {
errored: u64,
pending: u64,
},
SynchronizationDisabled,
EntryNotFound {
entry_id: String,
},
Expand Down Expand Up @@ -1818,6 +1819,9 @@ fn map_resend_outcome(result: OperationResult) -> Result<ResendEntryOutcome, Bin
errored: count_to_u64(report.errored)?,
pending: count_to_u64(report.pending)?,
},
uc_engine::ResendEntryOutcome::SynchronizationDisabled => {
ResendEntryOutcome::SynchronizationDisabled
}
uc_engine::ResendEntryOutcome::EntryNotFound { entry_id } => {
ResendEntryOutcome::EntryNotFound { entry_id }
}
Expand Down
69 changes: 67 additions & 2 deletions crates/uc-application/src/clipboard/inbound/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use uc_core::ids::DeviceId;
use uc_core::ports::security::TransferCipherPort;
use uc_core::ports::{
ClipboardReceiverPort, ClockPort, ConnectionChannel, InboundClipboard,
InboundClipboardDisposition, InboundClipboardReceipt,
InboundClipboardDisposition, InboundClipboardReceipt, SettingsPort,
};
use uc_core::MemberRepositoryPort;
use uc_observability_contract::analytics::{
Expand Down Expand Up @@ -61,6 +61,7 @@ pub struct ClipboardInboundRuntimeDeps {
pub receiver: Arc<dyn ClipboardReceiverPort>,
pub member_repo: Arc<dyn MemberRepositoryPort>,
pub transfer_cipher: Arc<dyn TransferCipherPort>,
pub settings: Arc<dyn SettingsPort>,
pub clock: Arc<dyn ClockPort>,
pub apply: Arc<dyn InboundClipboardApplyPort>,
pub events: Arc<dyn ClipboardInboundEventPort>,
Expand All @@ -79,6 +80,7 @@ pub struct ClipboardInboundRuntime {

struct InboundProcessor {
receive_gate: MemberReceiveGate,
settings: Arc<dyn SettingsPort>,
transfer_cipher: Arc<dyn TransferCipherPort>,
clock: Arc<dyn ClockPort>,
apply: Arc<dyn InboundClipboardApplyPort>,
Expand Down Expand Up @@ -117,6 +119,7 @@ impl ClipboardInboundRuntime {
let mut receiver = deps.receiver.subscribe();
let processor = InboundProcessor {
receive_gate: MemberReceiveGate::new(deps.member_repo),
settings: deps.settings,
transfer_cipher: deps.transfer_cipher,
clock: deps.clock,
apply: deps.apply,
Expand Down Expand Up @@ -278,6 +281,10 @@ impl InboundProcessor {
..InboundTiming::default()
};
let receiver_policy_started_at = Instant::now();
if !inbound_sync_enabled(self.settings.as_ref()).await {
receipt.finish(InboundClipboardDisposition::Rejected);
return None;
}
if !self
.receive_gate
.is_receive_allowed(&inbound.peer_device_id)
Expand Down Expand Up @@ -379,6 +386,19 @@ impl InboundProcessor {
}
}

async fn inbound_sync_enabled(settings: &dyn SettingsPort) -> bool {
match settings.load().await {
Ok(settings) => settings.sync.sync_enabled,
Err(_) => {
warn!(
error_kind = "settings_load",
"clipboard inbound: delivery rejected"
);
false
}
}
}

fn duration_ms(duration: Duration) -> u32 {
duration.as_millis().min(u32::MAX as u128) as u32
}
Expand Down Expand Up @@ -494,7 +514,7 @@ mod tests {
use uc_core::ports::security::{TransferCipherError, TransferCipherPort};
use uc_core::ports::{
ClipboardHeader, ClipboardReceiverPort, ClockPort, ConnectionChannel, InboundClipboard,
InboundClipboardDisposition, InboundClipboardReceipt, InboundClipboardResult,
InboundClipboardDisposition, InboundClipboardReceipt, InboundClipboardResult, SettingsPort,
};
use uc_core::security::IdentityFingerprint;
use uc_core::{
Expand All @@ -514,6 +534,23 @@ mod tests {
tx: broadcast::Sender<InboundClipboard>,
}

struct FixedSettings {
sync_enabled: bool,
}

#[async_trait]
impl SettingsPort for FixedSettings {
async fn load(&self) -> anyhow::Result<uc_core::settings::model::Settings> {
let mut settings = uc_core::settings::model::Settings::default();
settings.sync.sync_enabled = self.sync_enabled;
Ok(settings)
}

async fn save(&self, _settings: &uc_core::settings::model::Settings) -> anyhow::Result<()> {
Ok(())
}
}

impl FakeReceiver {
fn new() -> Self {
let (tx, _) = broadcast::channel(16);
Expand Down Expand Up @@ -831,6 +868,7 @@ mod tests {
receiver,
member_repo: Arc::new(AllowAllMembers),
transfer_cipher: Arc::new(EchoCipher),
settings: Arc::new(FixedSettings { sync_enabled: true }),
clock: Arc::new(FixedClock),
apply,
events,
Expand All @@ -848,6 +886,7 @@ mod tests {
receiver,
member_repo,
transfer_cipher,
settings: Arc::new(FixedSettings { sync_enabled: true }),
clock: Arc::new(FixedClock),
apply,
events,
Expand Down Expand Up @@ -1092,6 +1131,32 @@ mod tests {
runtime.shutdown().await.expect("runtime shutdown");
}

#[tokio::test]
async fn global_sync_disabled_rejects_before_decrypt_or_apply() {
let receiver = Arc::new(FakeReceiver::new());
let mut runtime_deps = deps(
Arc::clone(&receiver),
Arc::new(NeverApply),
Arc::new(RecordingEvents::default()),
);
runtime_deps.settings = Arc::new(FixedSettings {
sync_enabled: false,
});
runtime_deps.transfer_cipher = Arc::new(NeverCipher);
let runtime = ClipboardInboundRuntime::start(runtime_deps);
let (inbound, result) = fixture("peer-disabled", "hash-disabled");

receiver.publish(inbound);

assert_eq!(
tokio::time::timeout(Duration::from_secs(1), result.wait())
.await
.expect("receipt settled"),
Some(InboundClipboardDisposition::Rejected)
);
runtime.shutdown().await.expect("runtime shutdown");
}

#[tokio::test]
async fn unavailable_member_preferences_reject_before_decrypt_or_apply() {
for lookup in [MemberLookup::Missing, MemberLookup::Failed] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ impl RestoreBroadcastWorker {
// Re-read the toggle at emit time so a setting change between the
// restore and this debounced emit is respected.
let sync_on_restore = match self.settings.load().await {
Ok(settings) => settings.sync.sync_on_restore,
Ok(settings) => settings.sync.sync_enabled && settings.sync.sync_on_restore,
Err(err) => {
// Fail closed: if we can't confirm the user opted in, don't
// announce. A restore that should have broadcast is recovered
Expand Down
49 changes: 49 additions & 0 deletions crates/uc-application/src/clipboard/sync/outbound_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ impl OutboundSyncPlanner {
}
};

if !settings.sync.sync_enabled {
return OutboundSyncPlan {
clipboard: None,
files: vec![],
};
}
Comment on lines +85 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when settings loading fails.

The early error path at Lines 72-89 returns an enabled clipboard intent before this gate runs. A user with global synchronization disabled can transmit clipboard content when settings storage is unavailable. Return an empty plan on settings-load failure.

Proposed fix
-                // Safe default: allow clipboard sync, skip file sync.
                 return OutboundSyncPlan {
-                    clipboard: Some(ClipboardSyncIntent {
-                        snapshot,
-                        file_transfers: vec![],
-                    }),
+                    clipboard: None,
                     files: vec![],
                 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/uc-application/src/clipboard/sync/outbound_plan.rs` around lines 92 -
97, Update the settings-load error path in the outbound sync plan construction
to return an empty OutboundSyncPlan with no clipboard intent or files. Ensure
this failure handling occurs before the sync_enabled gate so unavailable
settings cannot permit clipboard transmission.


// File sync is only applicable for outbound, user-initiated origins
// (LocalCapture + Resend). RemotePush is already guarded above;
// LocalRestore writes the snapshot back to the local clipboard with no
Expand Down Expand Up @@ -193,6 +200,18 @@ mod tests {
OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings))))
}

fn planner_with_global_sync_disabled() -> OutboundSyncPlanner {
let mut settings = Settings::default();
settings.sync.sync_enabled = false;
OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings))))
}

fn planner_with_automatic_sync_disabled() -> OutboundSyncPlanner {
let mut settings = Settings::default();
settings.sync.auto_sync_enabled = false;
OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings))))
}

fn text_snapshot() -> SystemClipboardSnapshot {
SystemClipboardSnapshot {
ts_ms: 1_700_000_000_000,
Expand Down Expand Up @@ -328,4 +347,34 @@ mod tests {
plan.files.len()
);
}

#[tokio::test]
async fn resend_origin_is_rejected_when_global_sync_is_disabled() {
let plan = planner_with_global_sync_disabled()
.plan(
text_snapshot(),
ClipboardChangeOrigin::Resend,
vec![candidate("blocked.bin", 1)],
1,
)
.await;

assert!(plan.clipboard.is_none());
assert!(plan.files.is_empty());
}

#[tokio::test]
async fn resend_origin_keeps_file_sync_when_automatic_sync_is_disabled() {
let plan = planner_with_automatic_sync_disabled()
.plan(
text_snapshot(),
ClipboardChangeOrigin::Resend,
vec![candidate("manual.bin", 1)],
1,
)
.await;

assert_eq!(plan.files.len(), 1);
assert!(plan.clipboard.is_some(), "manual resend remains enabled");
}
}
3 changes: 3 additions & 0 deletions crates/uc-application/src/clipboard/sync/resend_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ pub struct ResendReport {
/// 错误集合,不向上漏出底层仓储 / dispatch 错误。
#[derive(Debug, Error)]
pub enum ResendEntryError {
#[error("synchronization is disabled")]
SynchronizationDisabled,

/// `entry_repo.get_entry` 返回 `None`。可能是 entry 已被用户删除,
/// 也可能是 UI 拿到一份过期视图后才点击重发。
#[error("entry not found: {0}")]
Expand Down
Loading
Loading