diff --git a/cli/Cargo.lock b/cli/Cargo.lock index e6a2204e..100d2c5b 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -1920,6 +1920,7 @@ dependencies = [ "sha2 0.11.0", "socket2 0.6.5", "thiserror 2.0.19", + "time", "tokio", "tokio-rustls", "tokio-stream", diff --git a/packages/core/Cargo.lock b/packages/core/Cargo.lock index 7279772c..ec9d3040 100644 --- a/packages/core/Cargo.lock +++ b/packages/core/Cargo.lock @@ -1403,6 +1403,7 @@ dependencies = [ "sha2 0.11.0", "socket2 0.6.5", "thiserror 2.0.19", + "time", "tokio", "tokio-rustls", "tokio-stream", diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index fbdbde40..3de4b522 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -28,6 +28,7 @@ serde_json = "1.0.151" sha2 = { version = "0.11.0", optional = true } socket2 = { version = "0.6.5", optional = true } thiserror = "2.0.19" +time = { version = "0.3.54", features = ["parsing"], optional = true } tokio = { version = "1.53.1", features = ["full"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"], optional = true } tokio-stream = "0.1.19" @@ -44,7 +45,7 @@ x509-parser = { version = "0.18.1", features = ["verify"], optional = true } default = [] crypto = ["ed25519-dalek", "rcgen", "rsa", "sha2", "tokio-util"] discovery = ["http", "multicast"] -http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "tokio-rustls", "tokio-util", "x509-parser"] +http = ["crypto", "form_urlencoded", "http-body-util", "hyper", "hyper-util", "if-addrs", "pem", "percent-encoding", "reqwest", "rustls", "socket2", "time", "tokio-rustls", "tokio-util", "x509-parser"] multicast = ["if-addrs", "socket2", "tokio-util"] webrtc-signaling = ["tokio-tungstenite"] webrtc = ["crypto", "flate2", "dep:webrtc", "webrtc-signaling", "x509-parser"] diff --git a/packages/core/src/http/server/common/save.rs b/packages/core/src/http/server/common/save.rs index 38a916bc..1e2a7e83 100644 --- a/packages/core/src/http/server/common/save.rs +++ b/packages/core/src/http/server/common/save.rs @@ -34,6 +34,9 @@ pub enum FileUploadTarget { /// The server writes the file to this path (created or truncated) /// and reports the result on `result_tx`. + /// + /// Timestamps provided in the sender's file metadata are applied to the + /// written file, so the application does not need to set them itself. Path { /// The path to write the file to. path: PathBuf, @@ -49,6 +52,9 @@ pub enum FileUploadTarget { /// The server writes the file to this raw file descriptor (Android only) /// and reports the result on `result_tx`. + /// + /// Timestamps provided in the sender's file metadata are applied through + /// the descriptor, which also covers SAF documents that have no path. #[cfg(target_os = "android")] Fd { /// The raw file descriptor to write the file to. @@ -65,6 +71,20 @@ pub enum FileUploadTarget { }, } +/// The sender-provided timestamps of an uploaded file, applied to the written +/// file for [FileUploadTarget::Path] and [FileUploadTarget::Fd]. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct FileTimestamps { + pub modified: Option, + pub accessed: Option, +} + +impl FileTimestamps { + fn is_empty(&self) -> bool { + self.modified.is_none() && self.accessed.is_none() + } +} + /// Outcome of receiving an uploaded file. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SaveResult { @@ -84,6 +104,7 @@ pub(crate) async fn save_req_to_target( target: FileUploadTarget, file_size: u64, expected_sha256: Option<&str>, + timestamps: FileTimestamps, ) -> SaveResult { use sha2::{Digest, Sha256}; @@ -109,6 +130,7 @@ pub(crate) async fn save_req_to_target( }, file_size, progress_tx, + timestamps, ); (binary_tx, result_rx, Some(result_tx)) } @@ -129,6 +151,7 @@ pub(crate) async fn save_req_to_target( }, file_size, progress_tx, + timestamps, ); (binary_tx, result_rx, Some(result_tx)) } @@ -226,13 +249,15 @@ fn spawn_file_writer( open: impl Future> + Send + 'static, expected_size: u64, progress_tx: Option>, + timestamps: FileTimestamps, ) -> (mpsc::Sender, oneshot::Receiver>) { let (binary_tx, mut binary_rx) = mpsc::channel::(UPLOAD_CHANNEL_CAPACITY); let (internal_tx, internal_rx) = oneshot::channel::>(); tokio::spawn(async move { let result = - write_file_from_receiver(open, expected_size, &mut binary_rx, progress_tx).await; + write_file_from_receiver(open, expected_size, &mut binary_rx, progress_tx, timestamps) + .await; // Unblock the request handler if it is still sending chunks. binary_rx.close(); let _ = internal_tx.send(result); @@ -248,11 +273,16 @@ fn spawn_file_writer( /// /// The file is truncated to the written size, so that a target that pointed at /// a longer, pre-existing file cannot keep a tail of the old content. +/// +/// The sender-provided `timestamps` are applied to the completely written +/// file. This happens on the still-open handle: an Android file descriptor has +/// no path to address the file by afterwards. async fn write_file_from_receiver( open: impl Future>, expected_size: u64, rx: &mut mpsc::Receiver, progress_tx: Option>, + timestamps: FileTimestamps, ) -> Result<(), String> { use tokio::io::AsyncWriteExt; @@ -295,5 +325,26 @@ async fn write_file_from_receiver( tracing::warn!("Could not truncate file to {written} bytes: {e}"); } + // The timestamps are applied last because the writes and the truncation + // above update the modification time themselves. + // + // Best-effort: the provider backing an Android file descriptor may not + // support changing timestamps, which must not fail the completed transfer. + if !timestamps.is_empty() { + let mut times = std::fs::FileTimes::new(); + if let Some(modified) = timestamps.modified { + times = times.set_modified(modified); + } + if let Some(accessed) = timestamps.accessed { + times = times.set_accessed(accessed); + } + let file = file.into_std().await; + // Also closes the file, off the async runtime like tokio::fs does. + let result = tokio::task::spawn_blocking(move || file.set_times(times)).await; + if let Ok(Err(e)) = result { + tracing::warn!("Could not set file timestamps: {e}"); + } + } + Ok(()) } diff --git a/packages/core/src/http/server/v2.rs b/packages/core/src/http/server/v2.rs index ad6298d3..9beb3e58 100644 --- a/packages/core/src/http/server/v2.rs +++ b/packages/core/src/http/server/v2.rs @@ -7,7 +7,7 @@ use crate::http::server::common::error::AppError; use crate::http::server::common::pin::check_pin; use crate::http::server::common::query::parse_query; use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse}; -use crate::http::server::common::save::{FileUploadTarget, SaveResult}; +use crate::http::server::common::save::{FileTimestamps, FileUploadTarget, SaveResult}; use crate::http::server::common::session::{ FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2, }; @@ -385,6 +385,13 @@ pub(crate) async fn upload( let file_size = file_dto.size; let expected_sha256 = file_dto.sha256.clone(); + let timestamps = match &file_dto.metadata { + Some(metadata) => FileTimestamps { + modified: metadata.modified_time(), + accessed: metadata.accessed_time(), + }, + None => FileTimestamps::default(), + }; let (target_tx, target_rx) = oneshot::channel::(); let event = ServerEventV2::FileUpload { @@ -403,8 +410,14 @@ pub(crate) async fn upload( return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)); }; - let result = - common::save::save_req_to_target(req, target, file_size, expected_sha256.as_deref()).await; + let result = common::save::save_req_to_target( + req, + target, + file_size, + expected_sha256.as_deref(), + timestamps, + ) + .await; upload_guard.finish(result).await; diff --git a/packages/core/src/model/transfer.rs b/packages/core/src/model/transfer.rs index 6713aa35..f15c9b96 100644 --- a/packages/core/src/model/transfer.rs +++ b/packages/core/src/model/transfer.rs @@ -115,3 +115,86 @@ pub struct FileMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub accessed: Option, } + +#[cfg(feature = "http")] +impl FileMetadata { + /// The `modified` timestamp parsed as a [`std::time::SystemTime`], + /// or `None` when absent or not parsable. + pub fn modified_time(&self) -> Option { + parse_timestamp(self.modified.as_deref()?) + } + + /// The `accessed` timestamp parsed as a [`std::time::SystemTime`], + /// or `None` when absent or not parsable. + pub fn accessed_time(&self) -> Option { + parse_timestamp(self.accessed.as_deref()?) + } +} + +/// Parses an RFC 3339 timestamp (e.g. `2026-08-01T10:20:30.456Z`), the format +/// the protocol uses for file metadata. +#[cfg(feature = "http")] +fn parse_timestamp(value: &str) -> Option { + match time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) { + Ok(parsed) => Some(parsed.into()), + Err(e) => { + tracing::warn!("Could not parse file timestamp {value:?}: {e}"); + None + } + } +} + +#[cfg(all(test, feature = "http"))] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + fn metadata(modified: &str) -> FileMetadata { + FileMetadata { + modified: Some(modified.to_string()), + accessed: None, + } + } + + #[test] + fn parses_utc_timestamp() { + assert_eq!( + metadata("2000-01-01T00:00:00Z").modified_time(), + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(946_684_800)), + ); + } + + #[test] + fn parses_fractional_seconds() { + // The Dart implementation sends `DateTime.toIso8601String()` of a UTC + // value, which includes fractional seconds: 1970-01-01T00:00:00.500Z. + assert_eq!( + metadata("1970-01-01T00:00:00.500Z").modified_time(), + Some(SystemTime::UNIX_EPOCH + Duration::from_millis(500)), + ); + } + + #[test] + fn parses_offset_timestamp() { + assert_eq!( + metadata("2000-01-01T01:00:00+01:00").modified_time(), + Some(SystemTime::UNIX_EPOCH + Duration::from_secs(946_684_800)), + ); + } + + #[test] + fn ignores_invalid_timestamp() { + assert_eq!(metadata("yesterday").modified_time(), None); + assert_eq!(metadata("2000-01-01T00:00:00").modified_time(), None); + } + + #[test] + fn ignores_absent_timestamp() { + let metadata = FileMetadata { + modified: None, + accessed: None, + }; + assert_eq!(metadata.modified_time(), None); + assert_eq!(metadata.accessed_time(), None); + } +} diff --git a/packages/core/tests/v2_server.rs b/packages/core/tests/v2_server.rs index 19157253..bef5c9e8 100644 --- a/packages/core/tests/v2_server.rs +++ b/packages/core/tests/v2_server.rs @@ -10,7 +10,7 @@ use localsend::http::server::common::save::FileUploadTarget; use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2}; use localsend::http::server::{start_with_port, ServerConfigV2}; use localsend::http::state::ClientInfo; -use localsend::model::transfer::FileDto; +use localsend::model::transfer::{FileDto, FileMetadata}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU16, AtomicU64, Ordering}; @@ -370,6 +370,61 @@ async fn test_full_upload_flow() { assert_status(result, 403); } +/// The sender-provided metadata timestamps are applied to the written file. +#[tokio::test] +async fn test_upload_applies_file_timestamps() { + let save_dir = std::env::temp_dir().join(format!("localsend-test-{}", uuid::Uuid::new_v4())); + tokio::fs::create_dir_all(&save_dir).await.unwrap(); + + let server = start_test_server(None, true, Some(save_dir.clone())).await; + let client = LsHttpClientV2::try_new_without_cert().unwrap(); + + let bytes = b"hello".to_vec(); + let mut file = file_dto("file-a", "a.bin", bytes.len() as u64); + file.metadata = Some(FileMetadata { + modified: Some("2020-08-15T10:20:30.500Z".to_string()), + accessed: None, + }); + + let response = client + .prepare_upload( + ProtocolType::Http, + "127.0.0.1", + server.port, + None, + prepare_upload_request(&[file]), + None, + CancellationToken::new(), + ) + .await + .unwrap() + .response + .unwrap(); + + upload_bytes( + &client, + server.port, + &response.session_id, + "file-a", + &response.files["file-a"], + &bytes, + ) + .await + .unwrap(); + + let modified = tokio::fs::metadata(save_dir.join("file-a")) + .await + .unwrap() + .modified() + .unwrap(); + assert_eq!( + modified, + std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(1_597_486_830_500), + ); + + tokio::fs::remove_dir_all(&save_dir).await.unwrap(); +} + #[tokio::test] async fn test_upload_with_matching_sha256() { let server = start_test_server(None, true, None).await; diff --git a/packages/localsend_isolates/lib/rust/api/server.dart b/packages/localsend_isolates/lib/rust/api/server.dart index ef799597..0c132acc 100644 --- a/packages/localsend_isolates/lib/rust/api/server.dart +++ b/packages/localsend_isolates/lib/rust/api/server.dart @@ -91,6 +91,9 @@ abstract class RsHttpServer implements RustOpaqueInterface { /// /// The progress (fraction of [file_size]) is emitted on [sink] /// while the file is being received. + /// + /// Timestamps provided in the sender's file metadata are applied to the + /// written file by the server. Stream respondFileUpload({required String sessionId, required String fileId, String? path, int? fileDescriptor, required BigInt fileSize}); /// Answers the pending [RsServerEvent::WebPrepareDownload] event. diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index be5863ee..cce9389b 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -6117,6 +6117,9 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { /// /// The progress (fraction of [file_size]) is emitted on [sink] /// while the file is being received. + /// + /// Timestamps provided in the sender's file metadata are applied to the + /// written file by the server. Stream respondFileUpload({ required String sessionId, required String fileId, diff --git a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart index 67e9c1eb..8ca69ee1 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart @@ -683,12 +683,6 @@ Future _handleFileUpload({ } try { - await applyFileTimestamps( - target: target, - lastModified: dartFile.metadata?.lastModified, - lastAccessed: dartFile.metadata?.lastAccessed, - ); - String? filePath; bool savedToGallery = false; if (shouldSaveToGallery) { diff --git a/packages/localsend_isolates/lib/src/task/server/file_saver.dart b/packages/localsend_isolates/lib/src/task/server/file_saver.dart index 8ce28897..fe86ad6e 100644 --- a/packages/localsend_isolates/lib/src/task/server/file_saver.dart +++ b/packages/localsend_isolates/lib/src/task/server/file_saver.dart @@ -115,29 +115,6 @@ Future reopenFileSaveTarget(FileSaveTarget target) async { ); } -/// Applies the file timestamps after the file has been written to a plain path. -Future applyFileTimestamps({ - required FileSaveTarget target, - DateTime? lastModified, - DateTime? lastAccessed, -}) async { - final path = target.path; - if (path == null) { - return; - } - final file = File(path); - if (lastModified != null) { - try { - await file.setLastModified(lastModified); - } catch (_) {} - } - if (lastAccessed != null) { - try { - await file.setLastAccessed(lastAccessed); - } catch (_) {} - } -} - /// Moves a file that has been written to the cache directory into the /// OS gallery (Photos/Videos). /// diff --git a/packages/localsend_isolates/lib/src/task/server/http_server.dart b/packages/localsend_isolates/lib/src/task/server/http_server.dart index 3cf31d13..48b2d2a1 100644 --- a/packages/localsend_isolates/lib/src/task/server/http_server.dart +++ b/packages/localsend_isolates/lib/src/task/server/http_server.dart @@ -57,6 +57,9 @@ class HttpServerService { /// The returned stream emits the progress (fraction of [fileSize]) while the /// file is being received and closes once the file has been received /// completely (or errors when saving failed). + /// + /// Timestamps provided in the sender's file metadata are applied to the + /// written file by the Rust server. Stream respondFileUpload({ required String sessionId, required String fileId, diff --git a/packages/localsend_isolates/pubspec.lock b/packages/localsend_isolates/pubspec.lock index 83ec11de..882c639b 100644 --- a/packages/localsend_isolates/pubspec.lock +++ b/packages/localsend_isolates/pubspec.lock @@ -640,10 +640,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" type_plus: dependency: transitive description: diff --git a/packages/localsend_isolates/rust/Cargo.lock b/packages/localsend_isolates/rust/Cargo.lock index fe086089..274ff386 100644 --- a/packages/localsend_isolates/rust/Cargo.lock +++ b/packages/localsend_isolates/rust/Cargo.lock @@ -1594,6 +1594,7 @@ dependencies = [ "sha2 0.11.0", "socket2 0.6.5", "thiserror 2.0.19", + "time", "tokio", "tokio-rustls", "tokio-stream", diff --git a/packages/localsend_isolates/rust/src/api/server.rs b/packages/localsend_isolates/rust/src/api/server.rs index 1ab02c23..e70e4bc9 100644 --- a/packages/localsend_isolates/rust/src/api/server.rs +++ b/packages/localsend_isolates/rust/src/api/server.rs @@ -388,6 +388,9 @@ impl RsHttpServer { /// /// The progress (fraction of [file_size]) is emitted on [sink] /// while the file is being received. + /// + /// Timestamps provided in the sender's file metadata are applied to the + /// written file by the server. pub async fn respond_file_upload( &self, sink: StreamSink,