mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
fix(security): address medium findings for ACL, limits, and UX
Tighten approve-endpoint to active shares with TTL sessions, reject non-file FDs, lower default ticket/approval/size caps, show endpoint IDs and Public-mode warnings, harden Android receive path checks, and run cargo-audit in CI.
This commit is contained in:
11
.cargo/audit.toml
Normal file
11
.cargo/audit.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Known transitive advisories we cannot fully clear without upstream iroh bumps.
|
||||||
|
# cargo audit in CI fails on new unlisted vulnerabilities.
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# rsa: Marvin timing side-channel; no fixed release; pulled by iroh stack.
|
||||||
|
"RUSTSEC-2023-0071",
|
||||||
|
# quick-xml / crossbeam-epoch: transitive; track via iroh/dependency updates.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
|
"RUSTSEC-2026-0204",
|
||||||
|
]
|
||||||
5
.github/workflows/rust-core.yml
vendored
5
.github/workflows/rust-core.yml
vendored
@@ -43,3 +43,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTDOCFLAGS: -D warnings
|
RUSTDOCFLAGS: -D warnings
|
||||||
run: cargo doc --workspace --no-deps
|
run: cargo doc --workspace --no-deps
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit --locked
|
||||||
|
- name: Audit Rust dependencies
|
||||||
|
# Ignores are listed in .cargo/audit.toml for known transitive issues.
|
||||||
|
run: cargo audit
|
||||||
|
|||||||
@@ -91,7 +91,12 @@ collection. Restart reconciliation never restores a stopped share.
|
|||||||
|
|
||||||
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
||||||
sizes, metadata, retained events, pending approvals, concurrent transfers, and
|
sizes, metadata, retained events, pending approvals, concurrent transfers, and
|
||||||
the event persistence queue. `initialize` uses conservative defaults;
|
the event persistence queue. `initialize` uses conservative defaults (including
|
||||||
|
bounded ticket size, pending approvals, and total collection bytes);
|
||||||
`initialize_with_limits` supports stricter deployments and tests. Cheap limits
|
`initialize_with_limits` supports stricter deployments and tests. Cheap limits
|
||||||
are checked before durable or network work, while remote collection limits are
|
are checked before durable or network work, while remote collection limits are
|
||||||
checked before downloading file content.
|
checked before downloading file content.
|
||||||
|
|
||||||
|
Manual `approve_endpoint_for_transfer` only applies to active shares, requires a
|
||||||
|
non-empty endpoint id, and creates a time-limited session (same TTL as handshake
|
||||||
|
approval), never a permanent grant.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ use tokio::sync::RwLock;
|
|||||||
use crate::api::TransferAccessMode;
|
use crate::api::TransferAccessMode;
|
||||||
use crate::util::now_ms;
|
use crate::util::now_ms;
|
||||||
|
|
||||||
|
/// Default lifetime for endpoint approval sessions (matches handshake approval TTL).
|
||||||
|
pub(crate) const APPROVAL_SESSION_TTL_MS: i64 = 10 * 60 * 1000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum AccessDecision {
|
pub(crate) enum AccessDecision {
|
||||||
Allow,
|
Allow,
|
||||||
@@ -42,7 +45,12 @@ impl AccessPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
|
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
|
||||||
self.approve_endpoint_until(transfer_id, endpoint_id, None)
|
// Never grant permanent sessions from the public API: always expire.
|
||||||
|
self.approve_endpoint_until(
|
||||||
|
transfer_id,
|
||||||
|
endpoint_id,
|
||||||
|
Some(now_ms() + APPROVAL_SESSION_TTL_MS),
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ impl Default for CoreLimits {
|
|||||||
Self {
|
Self {
|
||||||
max_sources: 128,
|
max_sources: 128,
|
||||||
max_collection_files: 10_000,
|
max_collection_files: 10_000,
|
||||||
max_total_bytes: 1024 * 1024 * 1024 * 1024,
|
// Cap extreme disk fill while still allowing multi-GB folders.
|
||||||
|
max_total_bytes: 256 * 1024 * 1024 * 1024,
|
||||||
max_path_bytes: 4_096,
|
max_path_bytes: 4_096,
|
||||||
max_ticket_bytes: 1024 * 1024,
|
// vnd1 tickets are small JSON+base64; 256 KiB is a generous ceiling.
|
||||||
|
max_ticket_bytes: 256 * 1024,
|
||||||
max_metadata_bytes: 16 * 1024,
|
max_metadata_bytes: 16 * 1024,
|
||||||
max_events: 500,
|
max_events: 500,
|
||||||
max_pending_approvals: 1_024,
|
// Bound handshake spam / notification pressure on the sender.
|
||||||
|
max_pending_approvals: 64,
|
||||||
max_concurrent_transfers: 8,
|
max_concurrent_transfers: 8,
|
||||||
event_queue_capacity: 1_024,
|
event_queue_capacity: 1_024,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tokio::sync::{oneshot, Mutex};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::AccessPolicy,
|
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
||||||
repository::{ReceiverRequestInsert, Repository},
|
repository::{ReceiverRequestInsert, Repository},
|
||||||
@@ -14,7 +14,7 @@ use crate::{
|
|||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
const APPROVAL_TTL_MS: i64 = 10 * 60 * 1000;
|
const APPROVAL_TTL_MS: i64 = APPROVAL_SESSION_TTL_MS;
|
||||||
const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
|
const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -582,6 +582,24 @@ fn duplicate_file_descriptor(value: &str) -> Result<OwnedFd> {
|
|||||||
if duplicated < 0 {
|
if duplicated < 0 {
|
||||||
return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor");
|
return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor");
|
||||||
}
|
}
|
||||||
let owned = unsafe { OwnedFd::from_raw_fd(duplicated) };
|
|
||||||
Ok(owned)
|
// Only regular files are valid share sources. Reject directories, sockets,
|
||||||
|
// and other fd types that a compromised UI could pass by integer.
|
||||||
|
let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
|
||||||
|
let rc = unsafe { libc::fstat(duplicated, stat.as_mut_ptr()) };
|
||||||
|
if rc != 0 {
|
||||||
|
let error = io::Error::last_os_error();
|
||||||
|
unsafe {
|
||||||
|
libc::close(duplicated);
|
||||||
|
}
|
||||||
|
return Err(error).context("failed to fstat file descriptor");
|
||||||
|
}
|
||||||
|
let mode = unsafe { stat.assume_init() }.st_mode;
|
||||||
|
if mode & libc::S_IFMT != libc::S_IFREG {
|
||||||
|
unsafe {
|
||||||
|
libc::close(duplicated);
|
||||||
|
}
|
||||||
|
anyhow::bail!("file descriptor must refer to a regular file");
|
||||||
|
}
|
||||||
|
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,21 @@ impl CoreInner {
|
|||||||
transfer_id: u64,
|
transfer_id: u64,
|
||||||
endpoint_id: String,
|
endpoint_id: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let endpoint_id = endpoint_id.trim().to_string();
|
||||||
|
if endpoint_id.is_empty() {
|
||||||
|
anyhow::bail!("endpoint id must not be empty");
|
||||||
|
}
|
||||||
|
if endpoint_id.len() as u64 > self.limits.max_metadata_bytes {
|
||||||
|
anyhow::bail!(
|
||||||
|
"endpoint id is {} bytes, limit is {}",
|
||||||
|
endpoint_id.len(),
|
||||||
|
self.limits.max_metadata_bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Only live shares can gain receiver sessions.
|
||||||
|
if !self.active_shares.lock().await.contains_key(&transfer_id) {
|
||||||
|
anyhow::bail!("transfer is not an active share");
|
||||||
|
}
|
||||||
self.access_policy
|
self.access_policy
|
||||||
.approve_endpoint(transfer_id, endpoint_id.clone())
|
.approve_endpoint(transfer_id, endpoint_id.clone())
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -82,3 +82,16 @@ async fn public_mode_allows_without_session() {
|
|||||||
);
|
);
|
||||||
assert_eq!(policy.decide(7, None).await, AccessDecision::Allow);
|
assert_eq!(policy.decide(7, None).await, AccessDecision::Allow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn approve_endpoint_grants_time_limited_session() {
|
||||||
|
let policy = AccessPolicy::new();
|
||||||
|
policy
|
||||||
|
.set_mode(11, TransferAccessMode::ApprovalRequired)
|
||||||
|
.await;
|
||||||
|
policy.approve_endpoint(11, "node-b".to_string()).await;
|
||||||
|
assert_eq!(
|
||||||
|
policy.decide(11, Some("node-b")).await,
|
||||||
|
AccessDecision::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,6 +62,26 @@ fn file_descriptor_source_rejects_invalid_values() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn file_descriptor_source_rejects_directory_fds() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let dir = std::fs::File::open(temp.path()).unwrap();
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let error = collect_import_files(vec![ShareSource {
|
||||||
|
kind: SourceKind::FileDescriptor,
|
||||||
|
value: dir.as_raw_fd().to_string(),
|
||||||
|
display_name: Some("folder".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
error.contains("regular file"),
|
||||||
|
"directory fd must be rejected: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn android_content_uri_must_be_opened_by_platform_code() {
|
fn android_content_uri_must_be_opened_by_platform_code() {
|
||||||
let error = collect_import_files(vec![ShareSource {
|
let error = collect_import_files(vec![ShareSource {
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ fn default_limits_are_valid() {
|
|||||||
CoreLimits::default().validate().unwrap();
|
CoreLimits::default().validate().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_limits_bound_ticket_and_approval_pressure() {
|
||||||
|
let limits = CoreLimits::default();
|
||||||
|
assert!(limits.max_ticket_bytes <= 256 * 1024);
|
||||||
|
assert!(limits.max_pending_approvals <= 64);
|
||||||
|
assert!(limits.max_total_bytes <= 256 * 1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn zero_limit_is_rejected() {
|
fn zero_limit_is_rejected() {
|
||||||
let limits = CoreLimits {
|
let limits = CoreLimits {
|
||||||
|
|||||||
@@ -251,6 +251,34 @@ fn access_mode_update_requires_active_persisted_share() {
|
|||||||
.is_err());
|
.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approve_endpoint_requires_active_share_and_nonempty_id() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let share = share_path(&sender.core, &source_path, 42, "shared.txt", false);
|
||||||
|
|
||||||
|
assert!(sender
|
||||||
|
.core
|
||||||
|
.approve_endpoint_for_transfer(share.transfer_id, " ".to_string())
|
||||||
|
.is_err());
|
||||||
|
assert!(sender
|
||||||
|
.core
|
||||||
|
.approve_endpoint_for_transfer(999, "endpoint-a".to_string())
|
||||||
|
.is_err());
|
||||||
|
sender
|
||||||
|
.core
|
||||||
|
.approve_endpoint_for_transfer(share.transfer_id, "endpoint-a".to_string())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
sender.core.cancel_transfer(share.transfer_id).unwrap();
|
||||||
|
assert!(sender
|
||||||
|
.core
|
||||||
|
.approve_endpoint_for_transfer(share.transfer_id, "endpoint-a".to_string())
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn source_limit_rejection_creates_no_transfer_state() {
|
fn source_limit_rejection_creates_no_transfer_state() {
|
||||||
let core_dir = tempfile::tempdir().unwrap();
|
let core_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -225,8 +225,7 @@ private class AndroidMediaStoreDownloadsSink(
|
|||||||
"MediaStore Downloads requires Android 10 or newer"
|
"MediaStore Downloads requires Android 10 or newer"
|
||||||
}
|
}
|
||||||
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
||||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
val parts = requireSafeRelativePathParts(relativePath)
|
||||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
|
||||||
val finalName = parts.last()
|
val finalName = parts.last()
|
||||||
val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
|
val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
|
||||||
check(!mediaStoreItemExists(finalName, relativeDir)) {
|
check(!mediaStoreItemExists(finalName, relativeDir)) {
|
||||||
@@ -346,6 +345,8 @@ private class AndroidTreeReceiveOutputSink(
|
|||||||
|
|
||||||
override fun startFile(relativePath: String) {
|
override fun startFile(relativePath: String) {
|
||||||
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
||||||
|
// Defense in depth: Rust also validates, but sinks must reject traversal alone.
|
||||||
|
requireSafeRelativePathParts(relativePath)
|
||||||
val (parent, finalName) = resolveParent(relativePath)
|
val (parent, finalName) = resolveParent(relativePath)
|
||||||
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" }
|
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" }
|
||||||
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
|
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
|
||||||
@@ -391,8 +392,7 @@ private class AndroidTreeReceiveOutputSink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveParent(relativePath: String): Pair<Uri, String> {
|
private fun resolveParent(relativePath: String): Pair<Uri, String> {
|
||||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
val parts = requireSafeRelativePathParts(relativePath)
|
||||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
|
||||||
var parent = DocumentsContract.buildDocumentUriUsingTree(
|
var parent = DocumentsContract.buildDocumentUriUsingTree(
|
||||||
treeUri,
|
treeUri,
|
||||||
DocumentsContract.getTreeDocumentId(treeUri),
|
DocumentsContract.getTreeDocumentId(treeUri),
|
||||||
@@ -428,3 +428,18 @@ private class AndroidTreeReceiveOutputSink(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a receive relative path and reject traversal / absolute-style components.
|
||||||
|
* Rust already validates; this keeps Android sinks safe if called incorrectly.
|
||||||
|
*/
|
||||||
|
private fun requireSafeRelativePathParts(relativePath: String): List<String> {
|
||||||
|
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
||||||
|
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
||||||
|
require(parts.none { part ->
|
||||||
|
part == "." || part == ".." || part.contains('\\') || part.contains('\u0000')
|
||||||
|
}) {
|
||||||
|
"relative path contains invalid components: $relativePath"
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
|
<string name="send_access_approval_description">You approve or refuse every new receiver.</string>
|
||||||
<string name="send_access_anyone">Anyone with this transfer</string>
|
<string name="send_access_anyone">Anyone with this transfer</string>
|
||||||
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
|
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
|
||||||
|
<string name="send_access_anyone_warning">Anyone who has the ticket can download until you stop the share. Do not use this for private or sensitive files.</string>
|
||||||
<string name="send_file_size_unknown">Size unavailable</string>
|
<string name="send_file_size_unknown">Size unavailable</string>
|
||||||
<string name="send_transfer_created">Transfer created.</string>
|
<string name="send_transfer_created">Transfer created.</string>
|
||||||
<string name="send_transfer_details_title">Transfer details</string>
|
<string name="send_transfer_details_title">Transfer details</string>
|
||||||
@@ -173,6 +174,7 @@
|
|||||||
<string name="value_on">On</string>
|
<string name="value_on">On</string>
|
||||||
<string name="value_off">Off</string>
|
<string name="value_off">Off</string>
|
||||||
<string name="approval_connection_request">Connection request</string>
|
<string name="approval_connection_request">Connection request</string>
|
||||||
|
<string name="approval_endpoint_id">Endpoint ID: %1$s</string>
|
||||||
<string name="approval_pending_count">%1$d requests are waiting</string>
|
<string name="approval_pending_count">%1$d requests are waiting</string>
|
||||||
<string name="core_status_ready">Ready</string>
|
<string name="core_status_ready">Ready</string>
|
||||||
<string name="event_log_title">Event log</string>
|
<string name="event_log_title">Event log</string>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class PendingApproval(
|
|||||||
val transferName: String,
|
val transferName: String,
|
||||||
val receiverName: String?,
|
val receiverName: String?,
|
||||||
val receiverDeviceName: String?,
|
val receiverDeviceName: String?,
|
||||||
|
/** Cryptographic peer identity from the Iroh connection — not display-name spoofable. */
|
||||||
|
val remoteEndpointId: String,
|
||||||
val requestedAt: Long,
|
val requestedAt: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +164,7 @@ private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
|
|||||||
transferName = transferName,
|
transferName = transferName,
|
||||||
receiverName = receiverName,
|
receiverName = receiverName,
|
||||||
receiverDeviceName = receiverDeviceName,
|
receiverDeviceName = receiverDeviceName,
|
||||||
|
remoteEndpointId = remoteEndpointId,
|
||||||
requestedAt = requestedAt,
|
requestedAt = requestedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors
|
|||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import vnidrop.shared.generated.resources.Res
|
import vnidrop.shared.generated.resources.Res
|
||||||
import vnidrop.shared.generated.resources.approval_connection_request
|
import vnidrop.shared.generated.resources.approval_connection_request
|
||||||
|
import vnidrop.shared.generated.resources.approval_endpoint_id
|
||||||
import vnidrop.shared.generated.resources.approval_pending_count
|
import vnidrop.shared.generated.resources.approval_pending_count
|
||||||
import vnidrop.shared.generated.resources.button_approve
|
import vnidrop.shared.generated.resources.button_approve
|
||||||
import vnidrop.shared.generated.resources.button_refuse
|
import vnidrop.shared.generated.resources.button_refuse
|
||||||
@@ -78,6 +79,12 @@ fun ApprovalModalHost(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = colors.foregroundLight,
|
color = colors.foregroundLight,
|
||||||
)
|
)
|
||||||
|
// Trusted identity is the endpoint id; display names are peer-provided.
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.approval_endpoint_id, request.remoteEndpointId),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = colors.foregroundLighter,
|
||||||
|
)
|
||||||
if (state.pending.size > 1) {
|
if (state.pending.size > 1) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(Res.string.approval_pending_count, state.pending.size),
|
stringResource(Res.string.approval_pending_count, state.pending.size),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import vnidrop.shared.generated.resources.field_sender_name
|
|||||||
import vnidrop.shared.generated.resources.field_transfer_name
|
import vnidrop.shared.generated.resources.field_transfer_name
|
||||||
import vnidrop.shared.generated.resources.send_access_anyone
|
import vnidrop.shared.generated.resources.send_access_anyone
|
||||||
import vnidrop.shared.generated.resources.send_access_anyone_description
|
import vnidrop.shared.generated.resources.send_access_anyone_description
|
||||||
|
import vnidrop.shared.generated.resources.send_access_anyone_warning
|
||||||
import vnidrop.shared.generated.resources.send_access_approval
|
import vnidrop.shared.generated.resources.send_access_approval
|
||||||
import vnidrop.shared.generated.resources.send_access_approval_description
|
import vnidrop.shared.generated.resources.send_access_approval_description
|
||||||
import vnidrop.shared.generated.resources.send_access_title
|
import vnidrop.shared.generated.resources.send_access_title
|
||||||
@@ -166,6 +167,13 @@ private fun ReviewFileStep(
|
|||||||
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
|
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
|
||||||
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
|
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
|
||||||
)
|
)
|
||||||
|
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
|
||||||
|
Text(
|
||||||
|
stringResource(Res.string.send_access_anyone_warning),
|
||||||
|
color = LocalVniDropColors.current.destructiveDefault,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (windowClass == WindowClass.Phone) {
|
if (windowClass == WindowClass.Phone) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ class FoundationComposeTest {
|
|||||||
transferName = "Photos",
|
transferName = "Photos",
|
||||||
receiverName = "Alice",
|
receiverName = "Alice",
|
||||||
receiverDeviceName = "Phone",
|
receiverDeviceName = "Phone",
|
||||||
|
remoteEndpointId = "endpoint-alice",
|
||||||
requestedAt = 1L,
|
requestedAt = 1L,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user