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 @@ -556,6 +556,27 @@ class PasteReleaseService(
)
}

/**
* Remote pastes bypass RemoveInvalidPlugin, so a paste whose content is unusable
* (e.g. an empty text flavor a peer captured from an empty clipboard) is dropped
* here. Storing it would only add a row the UI hides, relay it to other devices,
* and write an empty value that wipes the user's clipboard. Nothing meaningful is
* lost, so this is logged rather than notified; the remote ID is settled so a
* reconnect does not pull it again.
*/
private suspend fun discardInvalidRemotePasteContent(pasteData: PasteData): Boolean {
if (pasteData.hasValidContent()) return false
logger.warn {
"Discard remote paste with invalid content from ${pasteData.appInstanceId}: " +
"type=${pasteData.getTypeName()}"
}
pastePullCursorManager.settle(
appInstanceId = pasteData.appInstanceId,
remotePasteId = pasteData.id,
)
return true
}

/**
* [writePasteboard] only matters for file-type pastes, whose clipboard write
* happens later when the download finalizes. Non-file pastes write through
Expand Down Expand Up @@ -610,6 +631,7 @@ class PasteReleaseService(
return false
}
}
if (discardInvalidRemotePasteContent(pasteData)) return false
return true
}

Expand Down Expand Up @@ -721,6 +743,10 @@ class PasteReleaseService(
logger.warn { "releaseRemotePasteDataForPush: paste is not a file type (${pasteData.getType()})" }
return@withContext null
}
if (!pasteData.hasValidContent()) {
logger.warn { "releaseRemotePasteDataForPush: paste content is invalid (${pasteData.getType()})" }
return@withContext null
}
var createdPasteData: PasteData? = null
var renameMapToApply: Map<String, String>? = null
runCatching {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.crosspaste.paste.item.CreatePasteItemHelper.createTextPasteItem
import com.crosspaste.paste.item.PasteItemReader
import com.crosspaste.paste.item.TextPasteItem
import com.crosspaste.path.UserDataPathProvider
import com.crosspaste.presist.SingleFileInfoTree
import com.crosspaste.sync.PastePullCursorManager
import com.crosspaste.task.TaskBuilder
import com.crosspaste.task.TaskSubmitter
Expand Down Expand Up @@ -63,6 +64,14 @@ class PasteReleaseServiceRemoteDiscardTest {
userDataPathProvider = mockk<UserDataPathProvider>(relaxed = true),
)

/** Runs the submitted block so the clipboard/release side effects actually happen. */
private fun immediateSubmitter(): TaskSubmitter =
mockk<TaskSubmitter>().also { submitter ->
coEvery { submitter.submit(any()) } coAnswers {
firstArg<suspend TaskBuilder.() -> Unit>().invoke(mockk(relaxed = true))
}
}

private fun remotePasteData(item: TextPasteItem): PasteData =
PasteData(
appInstanceId = "remote-device",
Expand Down Expand Up @@ -124,18 +133,22 @@ class PasteReleaseServiceRemoteDiscardTest {
fun `remote paste within limit is released normally`() =
runBlocking {
val notificationManager = mockk<NotificationManager>(relaxed = true)
val taskSubmitter = mockk<TaskSubmitter>(relaxed = true)
val taskSubmitter = immediateSubmitter()
val service =
newService(
notificationManager = notificationManager,
taskSubmitter = taskSubmitter,
)

var wrotePasteboard = false
val result =
service.releaseRemotePasteData(remotePasteData(createTextPasteItem(text = "small"))) {}
service.releaseRemotePasteData(remotePasteData(createTextPasteItem(text = "small"))) {
wrotePasteboard = true
}

assertTrue(result.isSuccess)
assertNotNull(result.getOrThrow())
assertTrue(wrotePasteboard)
coVerify(exactly = 1) { taskSubmitter.submit(any()) }
verify(exactly = 0) { notificationManager.sendNotification(any(), any(), any(), any()) }
}
Expand All @@ -160,6 +173,100 @@ class PasteReleaseServiceRemoteDiscardTest {
verify(exactly = 0) { notificationManager.sendNotification(any(), any(), any(), any()) }
}

@Test
fun `remote paste with invalid content is discarded without touching the clipboard`() =
runBlocking {
val notificationManager = mockk<NotificationManager>(relaxed = true)
val pastePullCursorManager = mockk<PastePullCursorManager>(relaxed = true)
val taskSubmitter = immediateSubmitter()
val service =
newService(
notificationManager = notificationManager,
pastePullCursorManager = pastePullCursorManager,
taskSubmitter = taskSubmitter,
)

var wrotePasteboard = false
val result =
service.releaseRemotePasteData(
remotePasteData(createTextPasteItem(text = "")).copy(id = 100L),
) {
wrotePasteboard = true
}

assertTrue(result.isSuccess)
assertNull(result.getOrThrow())
assertTrue(!wrotePasteboard)
coVerify(exactly = 0) { taskSubmitter.submit(any()) }
coVerify(exactly = 1) { pastePullCursorManager.settle("remote-device", 100L) }
verify(exactly = 0) { notificationManager.sendNotification(any(), any(), any(), any()) }
}

/**
* Passes validateFileTransferMetadata (names match the tree, sizes add up) but a
* zero-byte file fails PasteFiles.isValid(), so only the content check rejects it.
*/
private fun contentInvalidFilePaste(): PasteData {
val item =
createFilesPasteItem(
relativePathList = listOf("empty.bin"),
fileInfoTreeMap = mapOf("empty.bin" to SingleFileInfoTree(0, "empty")),
)
return PasteData(
appInstanceId = "remote-device",
pasteAppearItem = item,
pasteCollection = PasteCollection(emptyList()),
pasteType = PasteType.FILE_TYPE.type,
source = null,
size = item.size,
hash = item.hash,
remote = true,
).copy(id = 100L)
}

@Test
fun `pulled file paste with invalid content is discarded before any download`() =
runBlocking {
val pastePullCursorManager = mockk<PastePullCursorManager>(relaxed = true)
val taskSubmitter = immediateSubmitter()
val service =
newService(
pastePullCursorManager = pastePullCursorManager,
taskSubmitter = taskSubmitter,
)

var wrotePasteboard = false
val result =
service.releaseRemotePasteData(contentInvalidFilePaste()) {
wrotePasteboard = true
}

assertTrue(result.isSuccess)
assertNull(result.getOrThrow())
assertTrue(!wrotePasteboard)
coVerify(exactly = 0) { taskSubmitter.submit(any()) }
coVerify(exactly = 1) { pastePullCursorManager.settle("remote-device", 100L) }
}

@Test
fun `pushed file paste with invalid content is rejected before a row is created`() =
runBlocking {
val pasteDao = mockk<PasteDao>(relaxed = true)
val pastePullCursorManager = mockk<PastePullCursorManager>(relaxed = true)
coEvery { pastePullCursorManager.hasActiveReceive(any()) } returns false
val service =
newService(
pasteDao = pasteDao,
pastePullCursorManager = pastePullCursorManager,
)

val prepared = service.releaseRemotePasteDataForPush(contentInvalidFilePaste())

assertNull(prepared)
coVerify(exactly = 0) { pastePullCursorManager.reserve(any(), any()) }
coVerify(exactly = 0) { pasteDao.createPasteData(any(), any()) }
}

@Test
fun `invalid remote file paste is discarded and the batch continues`() =
runBlocking {
Expand Down
43 changes: 23 additions & 20 deletions core/src/commonMain/kotlin/com/crosspaste/paste/PasteData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -142,28 +142,31 @@ data class PasteData(

fun isValid(): Boolean =
when (pasteState) {
PasteState.LOADED -> {
val pasteItem =
when (getType()) {
PasteType.TEXT_TYPE -> getPasteItem(PasteText::class)
PasteType.COLOR_TYPE -> getPasteItem(PasteColor::class)
PasteType.URL_TYPE -> getPasteItem(PasteUrl::class)
PasteType.HTML_TYPE -> getPasteItem(PasteHtml::class)
PasteType.RTF_TYPE -> getPasteItem(PasteRtf::class)
PasteType.IMAGE_TYPE -> getPasteItem(PasteImages::class)
PasteType.FILE_TYPE -> getPasteItem(PasteFiles::class)
else -> null
}
(pasteItem as? PasteItem)?.isValid() ?: false
}
PasteState.LOADING -> {
true
}
else -> {
false
}
PasteState.LOADED -> hasValidContent()
PasteState.LOADING -> true
else -> false
}

/**
* Whether the item that represents this paste carries usable content, regardless of
* [pasteState]. [isValid] answers true for LOADING because a local capture is not
* finished yet; a received paste is complete on arrival, so the receiver judges it here.
*/
fun hasValidContent(): Boolean {
val pasteItem =
when (getType()) {
PasteType.TEXT_TYPE -> getPasteItem(PasteText::class)
PasteType.COLOR_TYPE -> getPasteItem(PasteColor::class)
PasteType.URL_TYPE -> getPasteItem(PasteUrl::class)
PasteType.HTML_TYPE -> getPasteItem(PasteHtml::class)
PasteType.RTF_TYPE -> getPasteItem(PasteRtf::class)
PasteType.IMAGE_TYPE -> getPasteItem(PasteImages::class)
PasteType.FILE_TYPE -> getPasteItem(PasteFiles::class)
else -> null
}
return (pasteItem as? PasteItem)?.isValid() ?: false
}

fun isFileType(): Boolean =
when (getType()) {
PasteType.FILE_TYPE, PasteType.IMAGE_TYPE -> true
Expand Down
Loading