feat(zmodem): enhance file upload conflict handling and user prompts (#270)

- Introduced a new conflict mode for Zmodem uploads, allowing users to choose between "overwrite" and "skip" options when handling duplicate files.
- Updated the `TransferDuplicateDialog` to display different titles and descriptions based on the verification status of the upload request.
- Enhanced the backend logic to support conflict mode during file uploads, ensuring proper handling of user choices.
- Added new functions to resolve unverified uploads and manage conflict modes effectively.
This commit is contained in:
Kang committed 2026-07-17 17:31:17 +08:00
1 parent bcc9235163
commit 03b76c44dc
17 files changed
+310 -46

No files matched your search

+2
View File
@@ -25,6 +25,8 @@ pub enum Error {
Read(String),
#[error("out of memory")]
OutOfMemory,
#[error("remote aborted transfer")]
RemoteAborted,
#[error("unexpected CRC-16")]
UnexpectedCrc16,
#[error("unexpected CRC-32")]
+27 -2
View File
@@ -672,8 +672,9 @@ impl Sender {
fn queue_zfile(&mut self) -> Result<(), Error> {
let file_size = self.file_size;
let file_name = &self.file_name;
let file_options = self.file_options;
let mut writer = BufferWriter::new(&mut self.outgoing);
if write_zfile(&mut writer, &mut self.buf, file_name, file_size)?.is_none() {
if write_zfile(&mut writer, &mut self.buf, file_name, file_size, file_options)?.is_none() {
return Err(Error::OutOfMemory);
}
Ok(())
@@ -740,6 +741,8 @@ impl Sender {
match header.frame() {
Frame::ZRINIT => self.on_zrinit(header),
Frame::ZRPOS | Frame::ZACK => self.on_zrpos(header.count()),
Frame::ZSKIP => self.on_zskip(),
Frame::ZFERR | Frame::ZABORT | Frame::ZCAN => Err(Error::RemoteAborted),
Frame::ZFIN => self.on_zfin(),
_ => {
if self.state == SendState::WaitReceiverInit {
@@ -832,6 +835,27 @@ impl Sender {
Ok(())
}
fn on_zskip(&mut self) -> Result<(), Error> {
if matches!(
self.state,
SendState::WaitFilePos
| SendState::NeedFileData
| SendState::WaitFileAck
| SendState::WaitFileDone
) {
self.pending_request = None;
self.pending_event = Some(SenderEvent::FileComplete);
self.has_file = false;
if self.finish_requested {
self.queue_zfin()?;
self.state = SendState::WaitFinish;
} else {
self.state = SendState::ReadyForFile;
}
}
Ok(())
}
fn on_zfin(&mut self) -> Result<(), Error> {
if self.state == SendState::WaitFinish {
self.queue_oo()?;
@@ -1419,6 +1443,7 @@ fn write_zfile<P>(
buf: &mut Buffer<SUBPACKET_MAX_SIZE>,
name: &[u8],
size: u32,
file_options: [u8; 4],
) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
@@ -1430,7 +1455,7 @@ where
write!(buf, "{size}\0").map_err(|_| Error::OutOfMemory)?;
if Header::new(Encoding::ZBIN32, Frame::ZFILE, &[0; 4])
if Header::new(Encoding::ZBIN32, Frame::ZFILE, &file_options)
.write(port)?
.is_none()
{
+77 -2
View File
@@ -4,8 +4,8 @@
use rstest::rstest;
use zmodem2::{
Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, SubpacketType, XON, ZDLE, ZPAD,
Zrinit,
Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, SenderEvent, SubpacketType, XON,
ZDLE, ZPAD, ZfileManagementOption, Zrinit,
};
#[rstest]
@@ -70,6 +70,35 @@ fn write_zrpos(offset: u32) -> Vec<u8> {
wire
}
fn write_zskip() -> Vec<u8> {
let mut wire = Vec::new();
Header::new(Encoding::ZHEX, Frame::ZSKIP, &[0; 4])
.write(&mut wire)
.expect("write zskip")
.expect("complete zskip");
wire
}
fn write_zabort() -> Vec<u8> {
let mut wire = Vec::new();
Header::new(Encoding::ZHEX, Frame::ZABORT, &[0; 4])
.write(&mut wire)
.expect("write zabort")
.expect("complete zabort");
wire
}
fn zbin32_header_from_wire(wire: &[u8]) -> Header {
assert!(
wire.starts_with(&[ZPAD, ZDLE, Encoding::ZBIN32 as u8]),
"expected ZBIN32 header, got {wire:?}"
);
let mut port = &wire[2..];
Header::read(&mut port)
.expect("read header")
.expect("header")
}
fn has_subpacket_kind(wire: &[u8], kind: SubpacketType) -> bool {
wire.windows(2).any(|window| window == [ZDLE, kind as u8])
}
@@ -106,6 +135,52 @@ fn test_sender_requests_8k_file_chunks_after_large_zrinit() {
assert_eq!(request.len, 8 * 1024);
}
#[test]
fn test_sender_writes_zfile_management_options() {
let mut sender = Sender::new().unwrap();
sender.set_file_options(ZfileManagementOption::ZMCLOB);
sender.start_file(b"overwrite.bin", 1).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap();
let header = zbin32_header_from_wire(sender.drain_outgoing());
assert_eq!(header.frame(), Frame::ZFILE);
assert_eq!(
header.count().to_le_bytes(),
[1, ZfileManagementOption::ZMCLOB.bits(), 0, 0]
);
}
#[test]
fn test_sender_treats_zskip_as_file_complete() {
let mut sender = Sender::new().unwrap();
sender.set_file_options(ZfileManagementOption::ZMSKNOLOC);
sender.start_file(b"existing.bin", 1).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
sender.feed_incoming(&write_zskip()).unwrap();
assert_eq!(sender.poll_event(), Some(SenderEvent::FileComplete));
assert!(sender.poll_file().is_none());
}
#[test]
fn test_sender_reports_remote_abort() {
let mut sender = Sender::new().unwrap();
sender.start_file(b"blocked.bin", 1).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
assert_eq!(
sender.feed_incoming(&write_zabort()),
Err(zmodem2::Error::RemoteAborted)
);
}
#[test]
fn test_sender_uses_streaming_subpackets_until_ack_boundary() {
let mut sender = Sender::new().unwrap();