lamco-clipboard-core-0.6.0/.cargo_vcs_info.json0000644000000001711046102023000150360ustar { "git": { "sha1": "bd665e2fef8144524dcafe4ea3ff73b69c01f08c" }, "path_in_vcs": "crates/lamco-clipboard-core" }lamco-clipboard-core-0.6.0/CHANGELOG.md000064400000000000000000000114371046102023000154240ustar 00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.5.0] - 2025-12-30 ### Added - **DIBV5 format support** for transparent image clipboard operations - `CF_DIBV5` (format 17) - Windows BITMAPV5HEADER format constant - `png_to_dibv5()` - Convert PNG to DIBV5 with alpha channel preservation - `dibv5_to_png()` - Convert DIBV5 back to PNG - `jpeg_to_dibv5()` - Convert JPEG to DIBV5 - `dibv5_to_jpeg()` - Convert DIBV5 to JPEG - `has_transparency()` - Detect if image has transparent pixels - Full BITMAPV5HEADER parsing with color mask support ### Changed - `mime_to_rdp_formats()` now announces CF_DIBV5 for PNG sources (higher fidelity than CF_DIB) ## [0.4.0] - 2025-12-24 ### Added - **RTF format support** for Rich Text Format clipboard content - `validate_rtf()` - Validate RTF document structure - `is_rtf()` - Quick format detection - `text_to_rtf()` - Plain text to RTF conversion - `rtf_to_text()` - RTF to plain text extraction with proper group/destination handling - **Synthesized format support** for legacy Windows compatibility - `CF_TEXT` (format 1) - ANSI text using Windows-1252 codepage - `CF_OEMTEXT` (format 7) - DOS text using CP437 codepage - `text_to_ansi()` / `ansi_to_text()` - Windows-1252 conversion - `text_to_oem()` / `oem_to_text()` - CP437 conversion - Full codepage lookup tables for special character handling ### Changed - `mime_to_rdp_formats()` now announces CF_TEXT and CF_OEMTEXT alongside CF_UNICODETEXT ## [0.3.0] - 2025-12-23 ### Added - **FileGroupDescriptorW support** for RDP clipboard file transfer - `FileDescriptor` struct for parsing/building FILEDESCRIPTORW structures (592 bytes each) - `FileDescriptorFlags` for metadata field validation - `FileDescriptor::build()` to create descriptors from local files - `parse_list()` and `build_list()` for multiple file handling - `CF_FILEGROUPDESCRIPTORW` (49430) and `CF_FILECONTENTS` (49338) format constants - **Cross-platform filename sanitization module** (`sanitize.rs`) - Windows reserved name handling (CON, PRN, COM1-9, LPT1-9, AUX, NUL) - Invalid character filtering/replacement (\/:*?"<>|) - Trailing dots/spaces cleanup (Windows compatibility) - Line ending conversion (LF ↔ CRLF) - Path component extraction and validation ### Changed - Updated `mime_to_rdp_formats()` to advertise FileGroupDescriptorW for file URIs - Updated `rdp_format_to_mime()` to handle FileGroupDescriptorW format ## [0.2.0] - 2025-12-21 ### Added - `image` feature for image format conversion (PNG, JPEG, GIF, BMP) ## [0.1.1] - 2025-12-17 ### Fixed - Fixed docs.rs build failure by replacing deprecated `doc_auto_cfg` with `doc_cfg` - The `doc_auto_cfg` feature was removed in Rust 1.92.0 and merged into `doc_cfg` - Fixed code formatting issues in image module ## [0.1.0] - 2025-01-13 ### Added - Initial release - **`ClipboardSink` trait** - Protocol-agnostic clipboard backend interface - 7 async methods: `announce_formats`, `read_clipboard`, `write_clipboard`, `subscribe_changes`, `get_file_list`, `read_file_chunk`, `write_file` - `FileInfo` struct for file transfer metadata - `ClipboardChange` notification struct - `ClipboardChangeReceiver` for change subscriptions - **Format conversion** (`formats` module) - Windows clipboard format constants (CF_UNICODETEXT, CF_DIB, CF_HTML, etc.) - `ClipboardFormat` struct with ID and optional name - `mime_to_rdp_formats()` - Convert MIME types to RDP formats - `rdp_format_to_mime()` - Convert RDP format IDs to MIME types - `FormatConverter` for data conversion: - UTF-8 ↔ UTF-16LE (CF_UNICODETEXT) - HTML ↔ CF_HTML format - URI list ↔ HDROP format - **Loop detection** (`loop_detector` module) - `LoopDetector` - Prevent clipboard sync loops - SHA256-based format and content hashing - Configurable time window (default: 500ms) - `ClipboardSource` enum (Rdp, Local) - **Transfer engine** (`transfer` module) - `TransferEngine` - Chunked transfers for large data - Progress tracking with ETA calculation - SHA256 integrity verification - Configurable chunk size, max size, and timeout [0.5.0]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.5.0 [0.4.0]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.4.0 [0.3.0]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.3.0 [0.2.0]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.2.0 [0.1.1]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.1.1 [0.1.0]: https://github.com/lamco-admin/lamco-rdp/releases/tag/lamco-clipboard-core-v0.1.0 lamco-clipboard-core-0.6.0/Cargo.lock0000644000000225771046102023000130270ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder-lite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "fdeflate" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] [[package]] name = "flate2" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "gif" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", ] [[package]] name = "image" version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", "gif", "moxcms", "num-traits", "png", "zune-core", "zune-jpeg", ] [[package]] name = "lamco-clipboard-core" version = "0.6.0" dependencies = [ "bytes", "image", "sha2", "thiserror", "tracing", ] [[package]] name = "libc" version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", "simd-adler32", ] [[package]] name = "moxcms" version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "png" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ "bitflags", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] [[package]] name = "proc-macro2" version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "pxfm" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] [[package]] name = "quote" version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "syn" version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "zune-core" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" [[package]] name = "zune-jpeg" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" dependencies = [ "zune-core", ] lamco-clipboard-core-0.6.0/Cargo.toml0000644000000047751046102023000130520ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2024" rust-version = "1.85" name = "lamco-clipboard-core" version = "0.6.0" authors = ["Greg Lamberson "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Clipboard format conversion and synchronization utilities - MIME/Windows format mapping, loop detection, chunked transfer engine" homepage = "https://lamco.ai" documentation = "https://docs.rs/lamco-clipboard-core" readme = "README.md" keywords = [ "clipboard", "format", "conversion", "sync", "wayland", ] categories = [ "encoding", "data-structures", ] license = "MIT OR Apache-2.0" repository = "https://github.com/lamco-admin/lamco-rdp" resolver = "2" [package.metadata.docs.rs] all-features = true targets = ["x86_64-unknown-linux-gnu"] rustdoc-args = [ "--cfg", "docsrs", ] [badges.maintenance] status = "actively-developed" [features] default = [] image = [ "dep:image", "dep:bytes", ] [lib] name = "lamco_clipboard_core" path = "src/lib.rs" [dependencies.bytes] version = "1.5" optional = true [dependencies.image] version = "0.25" features = [ "png", "jpeg", "gif", "bmp", ] optional = true default-features = false [dependencies.sha2] version = "0.10" [dependencies.thiserror] version = "2" [dependencies.tracing] version = "0.1" [dev-dependencies] [lints.clippy] as_conversions = "allow" cast_lossless = "allow" cast_possible_truncation = "allow" cast_possible_wrap = "allow" large_futures = "warn" missing_errors_doc = "allow" missing_panics_doc = "allow" missing_safety_doc = "warn" module_name_repetitions = "allow" multiple_unsafe_ops_per_block = "warn" must_use_candidate = "allow" panic = "allow" rc_buffer = "warn" similar_names = "allow" undocumented_unsafe_blocks = "warn" unwrap_used = "allow" wildcard_imports = "warn" [lints.rust] elided_lifetimes_in_paths = "warn" invalid_reference_casting = "warn" single_use_lifetimes = "warn" unreachable_pub = "warn" unsafe_code = "warn" unsafe_op_in_unsafe_fn = "warn" unused_unsafe = "warn" lamco-clipboard-core-0.6.0/Cargo.toml.orig000064400000000000000000000021761046102023000165020ustar 00000000000000[package] name = "lamco-clipboard-core" version = "0.6.0" edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true authors.workspace = true description = "Clipboard format conversion and synchronization utilities - MIME/Windows format mapping, loop detection, chunked transfer engine" documentation = "https://docs.rs/lamco-clipboard-core" keywords = ["clipboard", "format", "conversion", "sync", "wayland"] categories = ["encoding", "data-structures"] readme = "README.md" [package.metadata.docs.rs] all-features = true targets = ["x86_64-unknown-linux-gnu"] rustdoc-args = ["--cfg", "docsrs"] [badges] maintenance = { status = "actively-developed" } [features] default = [] image = ["dep:image", "dep:bytes"] [lints] workspace = true [dependencies] sha2 = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } # Optional dependencies for image conversion image = { version = "0.25", optional = true, default-features = false, features = ["png", "jpeg", "gif", "bmp"] } bytes = { version = "1.5", optional = true } [dev-dependencies] lamco-clipboard-core-0.6.0/LICENSE-APACHE000064400000000000000000000131621046102023000155340ustar 00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work. "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall any Contributor be liable to You for damages. 9. Accepting Warranty or Additional Liability. END OF TERMS AND CONDITIONS Copyright 2025 Lamco Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. lamco-clipboard-core-0.6.0/LICENSE-MIT000064400000000000000000000020461046102023000152430ustar 00000000000000MIT License Copyright (c) 2025 Lamco Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. lamco-clipboard-core-0.6.0/README.md000064400000000000000000000167371046102023000151020ustar 00000000000000# lamco-clipboard-core [![Crates.io](https://img.shields.io/crates/v/lamco-clipboard-core.svg)](https://crates.io/crates/lamco-clipboard-core) [![Documentation](https://docs.rs/lamco-clipboard-core/badge.svg)](https://docs.rs/lamco-clipboard-core) [![License](https://img.shields.io/crates/l/lamco-clipboard-core.svg)](LICENSE-MIT) Protocol-agnostic clipboard utilities for Rust. This crate provides core clipboard functionality that can be used with any clipboard backend (Portal, X11, headless, etc.): - **`ClipboardSink` trait** - Abstract clipboard backend interface with 7 async methods - **`FormatConverter`** - MIME ↔ Windows clipboard format conversion - **`LoopDetector`** - Prevent clipboard sync loops with SHA256 content hashing - **`TransferEngine`** - Chunked transfer for large clipboard data with progress tracking ## Installation ```toml [dependencies] lamco-clipboard-core = "0.1" ``` ## Feature Flags ```toml [dependencies] # Default - text conversion, loop detection, transfer engine lamco-clipboard-core = "0.1" # With image format conversion (PNG/JPEG/BMP ↔ DIB) lamco-clipboard-core = { version = "0.1", features = ["image"] } ``` | Feature | Description | |---------|-------------| | `image` | Image format conversion - PNG, JPEG, BMP, GIF to/from Windows DIB format. Required for clipboard image sync. | ## Quick Start ```rust use lamco_clipboard_core::{FormatConverter, LoopDetector}; use lamco_clipboard_core::formats::{mime_to_rdp_formats, rdp_format_to_mime, CF_UNICODETEXT}; // Convert MIME types to RDP clipboard formats let formats = mime_to_rdp_formats(&["text/plain", "text/html"]); println!("RDP formats: {:?}", formats); // Convert RDP format back to MIME let mime = rdp_format_to_mime(CF_UNICODETEXT); assert_eq!(mime, Some("text/plain;charset=utf-8")); // Prevent clipboard sync loops let mut detector = LoopDetector::new(); if !detector.would_cause_loop(&formats) { // Safe to sync clipboard content } ``` ## Format Conversion Convert between UTF-8 text and Windows Unicode format: ```rust use lamco_clipboard_core::FormatConverter; let converter = FormatConverter::new(); // UTF-8 → UTF-16LE (for CF_UNICODETEXT) let unicode = converter.text_to_unicode("Hello, World!").unwrap(); // UTF-16LE → UTF-8 let text = converter.unicode_to_text(&unicode).unwrap(); assert_eq!(text, "Hello, World!"); ``` Convert HTML to Windows CF_HTML format: ```rust use lamco_clipboard_core::FormatConverter; let converter = FormatConverter::new(); let html = "Bold text"; let cf_html = converter.html_to_cf_html(html).unwrap(); let recovered = converter.cf_html_to_html(&cf_html).unwrap(); assert_eq!(recovered, html); ``` ## Loop Detection Prevent infinite clipboard sync loops between local and remote clipboards: ```rust use lamco_clipboard_core::{LoopDetector, ClipboardFormat, ClipboardSource}; let mut detector = LoopDetector::new(); // Record an operation from RDP let formats = vec![ClipboardFormat::unicode_text()]; detector.record_formats(&formats, ClipboardSource::Rdp); // Check if syncing back would cause a loop if detector.would_cause_loop(&formats) { println!("Loop detected - skipping sync"); } // Content-based deduplication let data = b"Clipboard content"; detector.record_content(data, ClipboardSource::Rdp); if detector.would_cause_content_loop(data, ClipboardSource::Local) { println!("Same content already synced"); } ``` ## Chunked Transfers Handle large clipboard data with progress tracking: ```rust use lamco_clipboard_core::TransferEngine; let mut engine = TransferEngine::new(); // Prepare data for chunked sending let data = vec![0u8; 1024 * 1024]; // 1MB let chunks = engine.prepare_send(&data).unwrap(); for (i, chunk) in chunks.iter().enumerate() { println!("Chunk {}/{}: {} bytes", i + 1, chunks.len(), chunk.len()); // Send chunk over network/RDP... } // Get hash for integrity verification let hash = engine.compute_hash(&data); ``` Receive chunked data: ```rust use lamco_clipboard_core::TransferEngine; let mut engine = TransferEngine::new(); // Start receiving engine.start_receive(1000, Some("expected_hash".to_string())).unwrap(); // Receive chunks engine.receive_chunk(vec![0u8; 500]).unwrap(); engine.receive_chunk(vec![0u8; 500]).unwrap(); // Check progress if let Some(progress) = engine.progress() { println!("Progress: {:.1}%", progress.percentage()); } // Finalize and verify integrity let data = engine.finalize_receive().unwrap(); ``` ## ClipboardSink Trait Implement this trait to create a clipboard backend: ```rust use lamco_clipboard_core::{ClipboardSink, ClipboardResult, ClipboardChangeReceiver, FileInfo}; struct MyClipboard { /* ... */ } impl ClipboardSink for MyClipboard { async fn announce_formats(&self, mime_types: Vec) -> ClipboardResult<()> { // Announce available formats to clipboard peers Ok(()) } async fn read_clipboard(&self, mime_type: &str) -> ClipboardResult> { // Read clipboard data for the given MIME type Ok(vec![]) } async fn write_clipboard(&self, mime_type: &str, data: Vec) -> ClipboardResult<()> { // Write data to clipboard Ok(()) } async fn subscribe_changes(&self) -> ClipboardResult { // Return a receiver for clipboard change notifications todo!() } async fn get_file_list(&self) -> ClipboardResult> { // Get list of files in clipboard (for file transfer) Ok(vec![]) } async fn read_file_chunk(&self, index: u32, offset: u64, size: u32) -> ClipboardResult> { // Read a chunk of a file (for MS-RDPECLIP FileContents) Ok(vec![]) } async fn write_file(&self, path: &str, data: Vec) -> ClipboardResult<()> { // Write a received file to destination Ok(()) } } ``` ## Image Conversion (requires `image` feature) Convert between Windows DIB format and standard image formats: ```rust use lamco_clipboard_core::image::{png_to_dib, dib_to_png, dib_dimensions}; // PNG → DIB (for sending to RDP client) let png_data = std::fs::read("image.png").unwrap(); let dib_data = png_to_dib(&png_data).unwrap(); // DIB → PNG (for receiving from RDP client) let png_result = dib_to_png(&dib_data).unwrap(); // Get dimensions without full decode let (width, height) = dib_dimensions(&dib_data).unwrap(); println!("Image: {}x{}", width, height); ``` Supported formats: PNG, JPEG, BMP, GIF (read-only). ## Supported Formats | Windows Format | Format ID | MIME Type | |---------------|-----------|-----------| | CF_UNICODETEXT | 13 | text/plain;charset=utf-8 | | CF_TEXT | 1 | text/plain | | CF_DIB | 8 | image/png | | CF_HDROP | 15 | text/uri-list | | HTML Format | 0xD010 | text/html | | PNG | 0xD011 | image/png | | JFIF | 0xD012 | image/jpeg | | GIF | 0xD013 | image/gif | | Rich Text Format | 0xD014 | text/rtf | ## About Lamco Lamco is a collection of high-quality, production-ready Rust crates for building Remote Desktop Protocol (RDP) applications. Built on top of [IronRDP](https://github.com/Devolutions/IronRDP), Lamco provides idiomatic Rust APIs with a focus on safety, performance, and ease of use. ## License Licensed under either of: - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) at your option. ## Contributing Contributions are welcome! Please see the [main repository](https://github.com/lamco-admin/lamco-rdp) for contribution guidelines. lamco-clipboard-core-0.6.0/src/error.rs000064400000000000000000000062721046102023000161020ustar 00000000000000//! Error types for clipboard operations. use thiserror::Error; /// Result type for clipboard operations pub type ClipboardResult = std::result::Result; /// Errors that can occur during clipboard operations #[derive(Error, Debug)] pub enum ClipboardError { /// Backend error (Portal, X11, etc.) #[error("backend error: {0}")] Backend(String), /// Format conversion failed #[error("format conversion failed: {0}")] FormatConversion(String), /// Unsupported clipboard format #[error("unsupported format: {0}")] UnsupportedFormat(String), /// Invalid UTF-8 data #[error("invalid UTF-8 data")] InvalidUtf8, /// Invalid UTF-16 data #[error("invalid UTF-16 data")] InvalidUtf16, /// Image decode error #[error("image decode error: {0}")] ImageDecode(String), /// Image encode error #[error("image encode error: {0}")] ImageEncode(String), /// Data size exceeded maximum #[error("data size {actual} exceeds maximum {max}")] DataSizeExceeded { /// Actual size in bytes actual: usize, /// Maximum allowed size in bytes max: usize, }, /// Transfer timeout #[error("transfer timeout after {0}ms")] TransferTimeout(u64), /// Transfer was cancelled #[error("transfer cancelled")] TransferCancelled, /// Loop detected - would cause clipboard sync loop #[error("clipboard loop detected")] LoopDetected, /// Invalid state for operation #[error("invalid state: {0}")] InvalidState(String), /// File not found #[error("file not found: {0}")] FileNotFound(String), /// Permission denied #[error("permission denied: {0}")] PermissionDenied(String), /// I/O error #[error("I/O error: {0}")] Io(#[from] std::io::Error), } impl ClipboardError { /// Returns true if this error is recoverable pub fn is_recoverable(&self) -> bool { matches!( self, Self::TransferTimeout(_) | Self::LoopDetected | Self::InvalidState(_) ) } /// Returns true if this error indicates a format issue pub fn is_format_error(&self) -> bool { matches!( self, Self::FormatConversion(_) | Self::UnsupportedFormat(_) | Self::InvalidUtf8 | Self::InvalidUtf16 | Self::ImageDecode(_) | Self::ImageEncode(_) ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_error_display() { let err = ClipboardError::FormatConversion("test".to_string()); assert_eq!(err.to_string(), "format conversion failed: test"); } #[test] fn test_is_recoverable() { assert!(ClipboardError::LoopDetected.is_recoverable()); assert!(ClipboardError::TransferTimeout(1000).is_recoverable()); assert!(!ClipboardError::InvalidUtf8.is_recoverable()); } #[test] fn test_is_format_error() { assert!(ClipboardError::InvalidUtf8.is_format_error()); assert!(ClipboardError::UnsupportedFormat("test".to_string()).is_format_error()); assert!(!ClipboardError::LoopDetected.is_format_error()); } } lamco-clipboard-core-0.6.0/src/formats.rs000064400000000000000000001251131046102023000164200ustar 00000000000000//! Clipboard format conversion utilities. //! //! This module handles conversion between MIME types and Windows clipboard format IDs, //! as well as data conversion between formats. use crate::{ClipboardError, ClipboardResult}; // ============================================================================= // Windows Clipboard Format IDs // ============================================================================= /// Standard Windows clipboard format: Unicode text (UTF-16LE) pub const CF_UNICODETEXT: u32 = 13; /// Standard Windows clipboard format: ANSI text (Windows-1252 codepage) pub const CF_TEXT: u32 = 1; /// Standard Windows clipboard format: OEM text (DOS codepage) /// Synthesized from CF_UNICODETEXT for very old applications pub const CF_OEMTEXT: u32 = 7; /// Standard Windows clipboard format: Device-independent bitmap pub const CF_DIB: u32 = 8; /// Standard Windows clipboard format: DIBV5 (Device-independent bitmap V5) /// Extended bitmap format with alpha channel and color space support (124-byte header) pub const CF_DIBV5: u32 = 17; /// Standard Windows clipboard format: File drop list pub const CF_HDROP: u32 = 15; /// Standard Windows clipboard format: Wave audio pub const CF_WAVE: u32 = 12; /// Standard Windows clipboard format: RIFF audio pub const CF_RIFF: u32 = 11; /// Custom format: HTML (registered format name: "HTML Format") pub const CF_HTML: u32 = 0xD010; /// Custom format: PNG image pub const CF_PNG: u32 = 0xD011; /// Custom format: JPEG image pub const CF_JPEG: u32 = 0xD012; /// Custom format: GIF image pub const CF_GIF: u32 = 0xD013; /// Custom format: Rich Text Format pub const CF_RTF: u32 = 0xD014; /// File transfer format: FileGroupDescriptorW (registered format name) /// Used for clipboard file transfer with delayed rendering (copy/paste, not drag/drop) /// Contains metadata about files without actual data pub const CF_FILEGROUPDESCRIPTORW: u32 = 49430; /// File transfer format: FileContents (registered format name) /// Used to retrieve actual file data chunks via FileContentsRequest/Response pub const CF_FILECONTENTS: u32 = 49338; // ============================================================================= // Clipboard Format // ============================================================================= /// A clipboard format with ID and optional name #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ClipboardFormat { /// Windows clipboard format ID pub id: u32, /// Format name (for registered formats) pub name: Option, } impl ClipboardFormat { /// Create a new clipboard format with ID only pub fn new(id: u32) -> Self { Self { id, name: None } } /// Create a new clipboard format with ID and name pub fn with_name(id: u32, name: impl Into) -> Self { Self { id, name: Some(name.into()), } } /// Create format for Unicode text pub fn unicode_text() -> Self { Self::new(CF_UNICODETEXT) } /// Create format for HTML pub fn html() -> Self { Self::with_name(CF_HTML, "HTML Format") } /// Create format for PNG pub fn png() -> Self { Self::with_name(CF_PNG, "PNG") } /// Create format for file drop pub fn file_drop() -> Self { Self::new(CF_HDROP) } } // ============================================================================= // MIME <-> Format Conversion // ============================================================================= /// Convert MIME types to RDP clipboard formats /// /// # Example /// /// ``` /// use lamco_clipboard_core::formats::mime_to_rdp_formats; /// /// let formats = mime_to_rdp_formats(&["text/plain", "text/html"]); /// assert!(!formats.is_empty()); /// ``` pub fn mime_to_rdp_formats(mime_types: &[&str]) -> Vec { let mut formats = Vec::new(); for mime in mime_types { match *mime { // Text formats - announce all synthesized text formats for compatibility // Windows auto-synthesizes between these, but we announce all for maximum compatibility "text/plain" | "text/plain;charset=utf-8" | "UTF8_STRING" | "STRING" => { if !formats.iter().any(|f: &ClipboardFormat| f.id == CF_UNICODETEXT) { // Primary format: Unicode (UTF-16LE) formats.push(ClipboardFormat::unicode_text()); // Synthesized: ANSI text for legacy applications formats.push(ClipboardFormat::new(CF_TEXT)); // Synthesized: OEM text for very old applications formats.push(ClipboardFormat::new(CF_OEMTEXT)); } } "text/html" => { formats.push(ClipboardFormat::html()); } "text/rtf" | "application/rtf" => { formats.push(ClipboardFormat::with_name(CF_RTF, "Rich Text Format")); } // Image formats "image/png" => { formats.push(ClipboardFormat::png()); // Also offer DIBV5 for alpha channel support (modern Windows apps prefer this) if !formats.iter().any(|f: &ClipboardFormat| f.id == CF_DIBV5) { formats.push(ClipboardFormat::new(CF_DIBV5)); } // Also offer DIB for legacy compatibility if !formats.iter().any(|f: &ClipboardFormat| f.id == CF_DIB) { formats.push(ClipboardFormat::new(CF_DIB)); } } "image/jpeg" | "image/jpg" => { formats.push(ClipboardFormat::with_name(CF_JPEG, "JFIF")); if !formats.iter().any(|f: &ClipboardFormat| f.id == CF_DIB) { formats.push(ClipboardFormat::new(CF_DIB)); } } "image/gif" => { formats.push(ClipboardFormat::with_name(CF_GIF, "GIF")); } "image/bmp" | "image/x-bmp" => { formats.push(ClipboardFormat::new(CF_DIB)); } // File formats - use RDP registered formats for clipboard file transfer "text/uri-list" | "x-special/gnome-copied-files" => { // For RDP file transfer, we need FileGroupDescriptorW (file list metadata) // and FileContents (actual file data retrieval) // ID 0 means it's a registered format - the name is what matters if !formats .iter() .any(|f: &ClipboardFormat| f.name.as_ref().is_some_and(|n| n == "FileGroupDescriptorW")) { formats.push(ClipboardFormat::with_name(0, "FileGroupDescriptorW")); formats.push(ClipboardFormat::with_name(0, "FileContents")); } } // Audio formats "audio/wav" | "audio/x-wav" => { formats.push(ClipboardFormat::new(CF_WAVE)); } _ => { // Unknown format - skip tracing::debug!("Unknown MIME type: {}", mime); } } } formats } /// Convert RDP format ID to preferred MIME type /// /// # Example /// /// ``` /// use lamco_clipboard_core::formats::{rdp_format_to_mime, CF_UNICODETEXT}; /// /// let mime = rdp_format_to_mime(CF_UNICODETEXT); /// assert_eq!(mime, Some("text/plain;charset=utf-8")); /// ``` pub fn rdp_format_to_mime(format_id: u32) -> Option<&'static str> { match format_id { // All text formats map to the same MIME type - we'll convert encoding as needed CF_UNICODETEXT | CF_TEXT | CF_OEMTEXT => Some("text/plain;charset=utf-8"), CF_HTML => Some("text/html"), CF_RTF => Some("text/rtf"), CF_DIB | CF_DIBV5 => Some("image/png"), // Prefer PNG output (preserves alpha from DIBV5) CF_PNG => Some("image/png"), CF_JPEG => Some("image/jpeg"), CF_GIF => Some("image/gif"), CF_HDROP | CF_FILEGROUPDESCRIPTORW => Some("text/uri-list"), CF_WAVE | CF_RIFF => Some("audio/wav"), // CF_FILECONTENTS is not mapped to MIME - it's a data retrieval mechanism, not a format _ => None, } } // ============================================================================= // Format Converter // ============================================================================= /// Handles clipboard data format conversion #[derive(Debug, Default)] pub struct FormatConverter { /// Maximum data size for conversion (default: 16MB) pub max_size: usize, } impl FormatConverter { /// Create a new format converter with default settings pub fn new() -> Self { Self { max_size: 16 * 1024 * 1024, // 16MB } } /// Create a format converter with custom max size pub fn with_max_size(max_size: usize) -> Self { Self { max_size } } /// Convert UTF-8 text to UTF-16LE (for CF_UNICODETEXT) /// /// Adds null terminator as required by Windows. pub fn text_to_unicode(&self, text: &str) -> ClipboardResult> { if text.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: text.len(), max: self.max_size, }); } let mut result: Vec = text.encode_utf16().flat_map(|c| c.to_le_bytes()).collect(); // Add null terminator (2 bytes for UTF-16) result.extend_from_slice(&[0, 0]); Ok(result) } /// Convert UTF-16LE to UTF-8 (from CF_UNICODETEXT) pub fn unicode_to_text(&self, data: &[u8]) -> ClipboardResult { if data.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.max_size, }); } if data.len() % 2 != 0 { return Err(ClipboardError::InvalidUtf16); } let utf16: Vec = data .chunks_exact(2) .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); // Remove null terminator if present let utf16 = if utf16.last() == Some(&0) { &utf16[..utf16.len() - 1] } else { &utf16[..] }; String::from_utf16(utf16).map_err(|_| ClipboardError::InvalidUtf16) } /// Convert UTF-8 text to ANSI (Windows-1252) for CF_TEXT /// /// Characters not representable in Windows-1252 are replaced with '?'. /// Adds null terminator as required by Windows. pub fn text_to_ansi(&self, text: &str) -> ClipboardResult> { if text.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: text.len(), max: self.max_size, }); } let mut result = Vec::with_capacity(text.len() + 1); for c in text.chars() { result.push(char_to_windows1252(c)); } // Add null terminator result.push(0); Ok(result) } /// Convert ANSI (Windows-1252) to UTF-8 (from CF_TEXT) pub fn ansi_to_text(&self, data: &[u8]) -> ClipboardResult { if data.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.max_size, }); } // Remove null terminator if present let data = if data.last() == Some(&0) { &data[..data.len() - 1] } else { data }; let result: String = data.iter().map(|&b| windows1252_to_char(b)).collect(); Ok(result) } /// Convert UTF-8 text to OEM (CP437) for CF_OEMTEXT /// /// Characters not representable in CP437 are replaced with '?'. /// Adds null terminator as required by Windows. pub fn text_to_oem(&self, text: &str) -> ClipboardResult> { if text.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: text.len(), max: self.max_size, }); } let mut result = Vec::with_capacity(text.len() + 1); for c in text.chars() { result.push(char_to_cp437(c)); } // Add null terminator result.push(0); Ok(result) } /// Convert OEM (CP437) to UTF-8 (from CF_OEMTEXT) pub fn oem_to_text(&self, data: &[u8]) -> ClipboardResult { if data.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.max_size, }); } // Remove null terminator if present let data = if data.last() == Some(&0) { &data[..data.len() - 1] } else { data }; let result: String = data.iter().map(|&b| cp437_to_char(b)).collect(); Ok(result) } /// Convert plain HTML to Windows CF_HTML format /// /// The CF_HTML format includes headers with byte offsets. pub fn html_to_cf_html(&self, html: &str) -> ClipboardResult> { if html.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: html.len(), max: self.max_size, }); } // CF_HTML format: // Version:0.9 // StartHTML:XXXXXXXX // EndHTML:XXXXXXXX // StartFragment:XXXXXXXX // EndFragment:XXXXXXXX // CONTENT let header_template = "Version:0.9\r\n\ StartHTML:XXXXXXXX\r\n\ EndHTML:XXXXXXXX\r\n\ StartFragment:XXXXXXXX\r\n\ EndFragment:XXXXXXXX\r\n"; let prefix = ""; let suffix = ""; let header_len = header_template.len(); let start_html = header_len; let start_fragment = header_len + prefix.len(); let end_fragment = start_fragment + html.len(); let end_html = end_fragment + suffix.len(); let header = format!( "Version:0.9\r\n\ StartHTML:{:08}\r\n\ EndHTML:{:08}\r\n\ StartFragment:{:08}\r\n\ EndFragment:{:08}\r\n", start_html, end_html, start_fragment, end_fragment ); let mut result = header; result.push_str(prefix); result.push_str(html); result.push_str(suffix); Ok(result.into_bytes()) } /// Extract HTML content from CF_HTML format pub fn cf_html_to_html(&self, data: &[u8]) -> ClipboardResult { let text = std::str::from_utf8(data).map_err(|_| ClipboardError::InvalidUtf8)?; // Parse StartFragment and EndFragment from header let start_fragment = Self::parse_header_value(text, "StartFragment:")?; let end_fragment = Self::parse_header_value(text, "EndFragment:")?; if start_fragment >= end_fragment || end_fragment > data.len() { return Err(ClipboardError::FormatConversion("invalid CF_HTML offsets".to_string())); } let fragment = &text[start_fragment..end_fragment]; Ok(fragment.to_string()) } /// Parse a numeric header value from CF_HTML fn parse_header_value(text: &str, key: &str) -> ClipboardResult { text.lines() .find(|line| line.starts_with(key)) .and_then(|line| line[key.len()..].trim().parse().ok()) .ok_or_else(|| ClipboardError::FormatConversion(format!("missing {} header", key))) } // ========================================================================= // RTF Format Support // ========================================================================= /// Validate and pass through RTF data /// /// RTF (Rich Text Format) is passed through without conversion since both /// Windows and Linux applications understand it natively. This method validates /// the RTF header and returns the data unchanged. /// /// # Arguments /// * `data` - Raw RTF data (must start with `{\rtf`) /// /// # Returns /// * The validated RTF data, or error if invalid pub fn validate_rtf(&self, data: &[u8]) -> ClipboardResult> { if data.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.max_size, }); } // RTF documents must start with {\rtf if !data.starts_with(b"{\\rtf") { return Err(ClipboardError::FormatConversion( "Invalid RTF: must start with {\\rtf".to_string(), )); } // Basic brace matching check let mut depth = 0i32; for &byte in data { match byte { b'{' => depth += 1, b'}' => depth -= 1, _ => {} } if depth < 0 { return Err(ClipboardError::FormatConversion( "Invalid RTF: unmatched closing brace".to_string(), )); } } if depth != 0 { return Err(ClipboardError::FormatConversion( "Invalid RTF: unmatched braces".to_string(), )); } Ok(data.to_vec()) } /// Check if data looks like valid RTF /// /// Quick validation without full parsing - useful for format detection. pub fn is_rtf(&self, data: &[u8]) -> bool { data.starts_with(b"{\\rtf") } /// Convert plain text to minimal RTF /// /// Creates a simple RTF document from plain text. Useful when RTF is requested /// but only plain text is available. pub fn text_to_rtf(&self, text: &str) -> ClipboardResult> { if text.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: text.len(), max: self.max_size, }); } let mut rtf = String::with_capacity(text.len() + 100); // RTF header: version 1, ANSI charset, default font rtf.push_str("{\\rtf1\\ansi\\deff0\n"); // Font table with a basic font rtf.push_str("{\\fonttbl{\\f0\\fswiss\\fcharset0 Arial;}}\n"); // Content for c in text.chars() { match c { '\\' => rtf.push_str("\\\\"), '{' => rtf.push_str("\\{"), '}' => rtf.push_str("\\}"), '\n' => rtf.push_str("\\par\n"), '\r' => {} // Skip CR, \n handles line breaks c if c.is_ascii() => rtf.push(c), c => { // Unicode escape: \uN? // The ? is the fallback character for non-Unicode readers rtf.push_str(&format!("\\u{}?", c as u32)); } } } rtf.push('}'); Ok(rtf.into_bytes()) } /// Extract plain text from RTF /// /// Performs basic RTF parsing to extract readable text content. /// This is a simplified parser that handles common cases. pub fn rtf_to_text(&self, data: &[u8]) -> ClipboardResult { if data.len() > self.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.max_size, }); } let text = std::str::from_utf8(data).map_err(|_| ClipboardError::InvalidUtf8)?; let mut result = String::new(); let mut chars = text.chars().peekable(); let mut skip_depth: Option = None; // Depth at which we started skipping let mut group_depth = 0i32; while let Some(c) = chars.next() { match c { '{' => { group_depth += 1; } '}' => { // If we were skipping and we're back to the skip start depth, stop skipping if let Some(sd) = skip_depth { if group_depth == sd { skip_depth = None; } } group_depth -= 1; } '\\' => { // Parse control word let mut control_word = String::new(); while let Some(&nc) = chars.peek() { if nc.is_ascii_alphabetic() { control_word.push(chars.next().unwrap()); } else { break; } } // Skip numeric parameter if present let mut has_param = false; while let Some(&nc) = chars.peek() { if nc.is_ascii_digit() || nc == '-' { chars.next(); has_param = true; } else { break; } } // Consume trailing space if present (part of control word) if chars.peek() == Some(&' ') && !has_param { chars.next(); } // Check for destination groups to skip // These are RTF groups that contain metadata, not document text let skip_destinations = [ "fonttbl", "colortbl", "stylesheet", "info", "pict", "header", "footer", "footnote", "annotation", "field", "fldinst", "datafield", "docvar", "xe", "tc", "rxe", ]; if skip_destinations.contains(&control_word.as_str()) { skip_depth = Some(group_depth); continue; } // Skip if we're in a destination group if skip_depth.is_some() { continue; } // Handle common control words match control_word.as_str() { "par" | "line" => result.push('\n'), "tab" => result.push('\t'), "" => { // Escaped character if let Some(escaped) = chars.next() { match escaped { '\\' | '{' | '}' => result.push(escaped), '\'' => { // Hex character \'xx let hex: String = chars.by_ref().take(2).collect(); if let Ok(byte) = u8::from_str_radix(&hex, 16) { result.push(byte as char); } } '*' => { // \* marks a destination - skip until end of current group skip_depth = Some(group_depth); } _ => {} } } } _ => {} // Ignore other control words } } _ if skip_depth.is_none() && c >= ' ' => { result.push(c); } _ => {} } } Ok(result) } /// Convert URI list to HDROP format (file paths) /// /// The HDROP format is a DROPFILES structure followed by null-terminated paths. pub fn uri_list_to_hdrop(&self, uri_list: &str) -> ClipboardResult> { let paths: Vec<&str> = uri_list .lines() .filter(|line| !line.starts_with('#')) .filter_map(|line| line.strip_prefix("file://")) .collect(); if paths.is_empty() { return Err(ClipboardError::FormatConversion("no valid file URIs".to_string())); } // DROPFILES structure (20 bytes): // DWORD pFiles (offset to file list) // POINT pt (unused, 8 bytes) // BOOL fNC (unused, 4 bytes) // BOOL fWide (TRUE for Unicode) let mut result = Vec::new(); // pFiles: offset 20 (size of DROPFILES) result.extend_from_slice(&20u32.to_le_bytes()); // pt.x, pt.y (unused) result.extend_from_slice(&0i32.to_le_bytes()); result.extend_from_slice(&0i32.to_le_bytes()); // fNC (unused) result.extend_from_slice(&0u32.to_le_bytes()); // fWide = TRUE (Unicode paths) result.extend_from_slice(&1u32.to_le_bytes()); // File paths as UTF-16LE, null-terminated for path in paths { // URL decode the path let decoded = percent_decode(path); for c in decoded.encode_utf16() { result.extend_from_slice(&c.to_le_bytes()); } // Null terminator result.extend_from_slice(&[0, 0]); } // Final double null terminator result.extend_from_slice(&[0, 0]); Ok(result) } /// Convert HDROP format to URI list pub fn hdrop_to_uri_list(&self, data: &[u8]) -> ClipboardResult { if data.len() < 20 { return Err(ClipboardError::FormatConversion("HDROP too small".to_string())); } // Read DROPFILES header let p_files = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize; let f_wide = u32::from_le_bytes([data[16], data[17], data[18], data[19]]) != 0; if p_files >= data.len() { return Err(ClipboardError::FormatConversion("invalid pFiles offset".to_string())); } let mut paths = Vec::new(); let file_data = &data[p_files..]; if f_wide { // UTF-16LE paths let mut pos = 0; while pos + 2 <= file_data.len() { let mut path_chars = Vec::new(); while pos + 2 <= file_data.len() { let c = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]); pos += 2; if c == 0 { break; } path_chars.push(c); } if path_chars.is_empty() { break; // Double null = end } if let Ok(path) = String::from_utf16(&path_chars) { paths.push(format!("file://{}", percent_encode(&path))); } } } else { // ANSI paths (rare) let mut pos = 0; while pos < file_data.len() { let end = file_data[pos..] .iter() .position(|&b| b == 0) .unwrap_or(file_data.len() - pos); if end == 0 { break; } if let Ok(path) = std::str::from_utf8(&file_data[pos..pos + end]) { paths.push(format!("file://{}", percent_encode(path))); } pos += end + 1; } } Ok(paths.join("\r\n")) } } // ============================================================================= // URL Encoding Helpers // ============================================================================= /// Percent-decode a URL path fn percent_decode(input: &str) -> String { let mut result = String::new(); let mut chars = input.chars().peekable(); while let Some(c) = chars.next() { if c == '%' { let hex: String = chars.by_ref().take(2).collect(); if let Ok(byte) = u8::from_str_radix(&hex, 16) { result.push(byte as char); } else { result.push('%'); result.push_str(&hex); } } else { result.push(c); } } result } /// Percent-encode special characters in a path fn percent_encode(input: &str) -> String { let mut result = String::new(); for c in input.chars() { match c { ' ' => result.push_str("%20"), '#' => result.push_str("%23"), '%' => result.push_str("%25"), '?' => result.push_str("%3F"), _ => result.push(c), } } result } // ============================================================================= // Codepage Conversion Helpers (Synthesized Format Support) // ============================================================================= /// Convert a Unicode character to Windows-1252 (Western European) /// /// Returns '?' for characters not representable in Windows-1252. fn char_to_windows1252(c: char) -> u8 { let cp = c as u32; // ASCII range (0-127) maps directly if cp < 128 { return cp as u8; } // Windows-1252 specific mappings (128-159 range has special characters) // 160-255 mostly match Latin-1 Supplement match cp { // 128-159: Windows-1252 specific characters 0x20AC => 128, // € 0x201A => 130, // ‚ 0x0192 => 131, // ƒ 0x201E => 132, // „ 0x2026 => 133, // … 0x2020 => 134, // † 0x2021 => 135, // ‡ 0x02C6 => 136, // ˆ 0x2030 => 137, // ‰ 0x0160 => 138, // Š 0x2039 => 139, // ‹ 0x0152 => 140, // Œ 0x017D => 142, // Ž 0x2018 => 145, // ' 0x2019 => 146, // ' 0x201C => 147, // " 0x201D => 148, // " 0x2022 => 149, // • 0x2013 => 150, // – 0x2014 => 151, // — 0x02DC => 152, // ˜ 0x2122 => 153, // ™ 0x0161 => 154, // š 0x203A => 155, // › 0x0153 => 156, // œ 0x017E => 158, // ž 0x0178 => 159, // Ÿ // 160-255: Latin-1 Supplement (direct mapping) 160..=255 => cp as u8, // Not representable _ => b'?', } } /// Convert a Windows-1252 byte to Unicode character fn windows1252_to_char(b: u8) -> char { // ASCII range maps directly if b < 128 { return b as char; } // 160-255 range matches Latin-1 Supplement if b >= 160 { return char::from_u32(b as u32).unwrap_or('?'); } // 128-159: Windows-1252 specific characters match b { 128 => '€', 130 => '‚', 131 => 'ƒ', 132 => '„', 133 => '…', 134 => '†', 135 => '‡', 136 => 'ˆ', 137 => '‰', 138 => 'Š', 139 => '‹', 140 => 'Œ', 142 => 'Ž', 145 => '\u{2018}', // ' 146 => '\u{2019}', // ' 147 => '\u{201C}', // " 148 => '\u{201D}', // " 149 => '•', 150 => '–', 151 => '—', 152 => '˜', 153 => '™', 154 => 'š', 155 => '›', 156 => 'œ', 158 => 'ž', 159 => 'Ÿ', // Undefined positions (129, 141, 143, 144, 157) _ => '?', } } /// Convert a Unicode character to CP437 (OEM/DOS codepage) /// /// Returns '?' for characters not representable in CP437. fn char_to_cp437(c: char) -> u8 { let cp = c as u32; // ASCII printable range (32-126) maps directly if (32..127).contains(&cp) { return cp as u8; } // Control characters (0-31) - map directly for compatibility if cp < 32 { return cp as u8; } // CP437 high characters (128-255) - common ones match cp { 0x00C7 => 128, // Ç 0x00FC => 129, // ü 0x00E9 => 130, // é 0x00E2 => 131, // â 0x00E4 => 132, // ä 0x00E0 => 133, // à 0x00E5 => 134, // å 0x00E7 => 135, // ç 0x00EA => 136, // ê 0x00EB => 137, // ë 0x00E8 => 138, // è 0x00EF => 139, // ï 0x00EE => 140, // î 0x00EC => 141, // ì 0x00C4 => 142, // Ä 0x00C5 => 143, // Å 0x00C9 => 144, // É 0x00E6 => 145, // æ 0x00C6 => 146, // Æ 0x00F4 => 147, // ô 0x00F6 => 148, // ö 0x00F2 => 149, // ò 0x00FB => 150, // û 0x00F9 => 151, // ù 0x00FF => 152, // ÿ 0x00D6 => 153, // Ö 0x00DC => 154, // Ü 0x00A2 => 155, // ¢ 0x00A3 => 156, // £ 0x00A5 => 157, // ¥ 0x20A7 => 158, // ₧ 0x0192 => 159, // ƒ 0x00E1 => 160, // á 0x00ED => 161, // í 0x00F3 => 162, // ó 0x00FA => 163, // ú 0x00F1 => 164, // ñ 0x00D1 => 165, // Ñ 0x00AA => 166, // ª 0x00BA => 167, // º 0x00BF => 168, // ¿ 0x00A1 => 173, // ¡ 0x00AB => 174, // « 0x00BB => 175, // » 0x00B0 => 248, // ° 0x00B7 => 249, // · 0x00B2 => 253, // ² _ => b'?', } } /// Convert a CP437 byte to Unicode character fn cp437_to_char(b: u8) -> char { // CP437 lookup table for 128-255 const CP437_HIGH: [char; 128] = [ 'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', 'É', 'æ', 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', 'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', '░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐', '└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧', '╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', '▀', 'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩', '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', ' ', ]; if b < 32 { // Control characters - return as-is or map to space char::from_u32(b as u32).unwrap_or(' ') } else if b < 127 { // ASCII printable b as char } else if b == 127 { // DEL character '⌂' } else { // High characters (128-255) CP437_HIGH[(b - 128) as usize] } } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn test_mime_to_formats() { let formats = mime_to_rdp_formats(&["text/plain", "text/html"]); assert!(formats.iter().any(|f| f.id == CF_UNICODETEXT)); assert!(formats.iter().any(|f| f.id == CF_HTML)); } #[test] fn test_format_to_mime() { assert_eq!(rdp_format_to_mime(CF_UNICODETEXT), Some("text/plain;charset=utf-8")); assert_eq!(rdp_format_to_mime(CF_HTML), Some("text/html")); assert_eq!(rdp_format_to_mime(CF_PNG), Some("image/png")); assert_eq!(rdp_format_to_mime(CF_FILEGROUPDESCRIPTORW), Some("text/uri-list")); assert_eq!(rdp_format_to_mime(49430), Some("text/uri-list")); assert_eq!(rdp_format_to_mime(0xFFFF), None); } #[test] fn test_text_to_unicode() { let converter = FormatConverter::new(); let result = converter.text_to_unicode("Hello").unwrap(); // "Hello" in UTF-16LE + null terminator assert_eq!( result, vec![ b'H', 0, b'e', 0, b'l', 0, b'l', 0, b'o', 0, // "Hello" 0, 0 // null terminator ] ); } #[test] fn test_unicode_to_text() { let converter = FormatConverter::new(); let data = vec![b'H', 0, b'i', 0, 0, 0]; // "Hi" + null let result = converter.unicode_to_text(&data).unwrap(); assert_eq!(result, "Hi"); } #[test] fn test_html_roundtrip() { let converter = FormatConverter::new(); let html = "Hello"; let cf_html = converter.html_to_cf_html(html).unwrap(); let recovered = converter.cf_html_to_html(&cf_html).unwrap(); assert_eq!(recovered, html); } #[test] fn test_clipboard_format_builders() { let text = ClipboardFormat::unicode_text(); assert_eq!(text.id, CF_UNICODETEXT); assert!(text.name.is_none()); let html = ClipboardFormat::html(); assert_eq!(html.id, CF_HTML); assert_eq!(html.name, Some("HTML Format".to_string())); } #[test] fn test_uri_list_to_hdrop() { let converter = FormatConverter::new(); let uri_list = "file:///home/user/test.txt"; let hdrop = converter.uri_list_to_hdrop(uri_list).unwrap(); // Check DROPFILES header assert_eq!(hdrop[0..4], 20u32.to_le_bytes()); // pFiles assert_eq!(hdrop[16..20], 1u32.to_le_bytes()); // fWide = TRUE } #[test] fn test_hdrop_roundtrip() { let converter = FormatConverter::new(); let original = "file:///home/user/test.txt"; let hdrop = converter.uri_list_to_hdrop(original).unwrap(); let recovered = converter.hdrop_to_uri_list(&hdrop).unwrap(); assert_eq!(recovered, original); } #[test] fn test_text_to_ansi() { let converter = FormatConverter::new(); let result = converter.text_to_ansi("Hello").unwrap(); assert_eq!(result, vec![b'H', b'e', b'l', b'l', b'o', 0]); } #[test] fn test_ansi_to_text() { let converter = FormatConverter::new(); let data = vec![b'H', b'i', 0]; let result = converter.ansi_to_text(&data).unwrap(); assert_eq!(result, "Hi"); } #[test] fn test_ansi_roundtrip_special_chars() { let converter = FormatConverter::new(); // Test Euro sign and em-dash (Windows-1252 specific) let text = "Price: \u{20AC}100 \u{2014} test"; let ansi = converter.text_to_ansi(text).unwrap(); let recovered = converter.ansi_to_text(&ansi).unwrap(); assert_eq!(recovered, text); } #[test] fn test_text_to_oem() { let converter = FormatConverter::new(); let result = converter.text_to_oem("Hello").unwrap(); assert_eq!(result, vec![b'H', b'e', b'l', b'l', b'o', 0]); } #[test] fn test_oem_to_text() { let converter = FormatConverter::new(); let data = vec![b'H', b'i', 0]; let result = converter.oem_to_text(&data).unwrap(); assert_eq!(result, "Hi"); } #[test] fn test_synthesized_text_formats_announced() { // Verify that announcing text also announces synthesized formats let formats = mime_to_rdp_formats(&["text/plain"]); assert!(formats.iter().any(|f| f.id == CF_UNICODETEXT)); assert!(formats.iter().any(|f| f.id == CF_TEXT)); assert!(formats.iter().any(|f| f.id == CF_OEMTEXT)); } // ========================================================================= // RTF Tests // ========================================================================= #[test] fn test_validate_rtf_valid() { let converter = FormatConverter::new(); let rtf = b"{\\rtf1\\ansi Hello World}"; let result = converter.validate_rtf(rtf); assert!(result.is_ok()); assert_eq!(result.unwrap(), rtf.to_vec()); } #[test] fn test_validate_rtf_invalid_header() { let converter = FormatConverter::new(); let not_rtf = b"Hello World"; let result = converter.validate_rtf(not_rtf); assert!(result.is_err()); } #[test] fn test_validate_rtf_unmatched_braces() { let converter = FormatConverter::new(); let rtf = b"{\\rtf1\\ansi Hello {World"; let result = converter.validate_rtf(rtf); assert!(result.is_err()); } #[test] fn test_is_rtf() { let converter = FormatConverter::new(); assert!(converter.is_rtf(b"{\\rtf1 test}")); assert!(!converter.is_rtf(b"plain text")); } #[test] fn test_text_to_rtf() { let converter = FormatConverter::new(); let result = converter.text_to_rtf("Hello\nWorld").unwrap(); let rtf_str = std::str::from_utf8(&result).unwrap(); assert!(rtf_str.starts_with("{\\rtf1")); assert!(rtf_str.contains("Hello")); assert!(rtf_str.contains("\\par")); assert!(rtf_str.contains("World")); assert!(rtf_str.ends_with("}")); } #[test] fn test_text_to_rtf_escapes() { let converter = FormatConverter::new(); let result = converter.text_to_rtf("Test {braces} and \\backslash").unwrap(); let rtf_str = std::str::from_utf8(&result).unwrap(); assert!(rtf_str.contains("\\{braces\\}")); assert!(rtf_str.contains("\\\\backslash")); } #[test] fn test_rtf_to_text_simple() { let converter = FormatConverter::new(); let rtf = b"{\\rtf1\\ansi Hello World}"; let result = converter.rtf_to_text(rtf).unwrap(); assert_eq!(result.trim(), "Hello World"); } #[test] fn test_rtf_to_text_with_formatting() { let converter = FormatConverter::new(); let rtf = b"{\\rtf1\\ansi{\\b Bold} and {\\i italic}}"; let result = converter.rtf_to_text(rtf).unwrap(); assert!(result.contains("Bold")); assert!(result.contains("italic")); } #[test] fn test_rtf_to_text_with_paragraphs() { let converter = FormatConverter::new(); let rtf = b"{\\rtf1\\ansi Line1\\par Line2}"; let result = converter.rtf_to_text(rtf).unwrap(); assert!(result.contains("Line1\nLine2")); } #[test] fn test_rtf_roundtrip() { let converter = FormatConverter::new(); let original = "Hello World!\nSecond line."; let rtf = converter.text_to_rtf(original).unwrap(); let recovered = converter.rtf_to_text(&rtf).unwrap(); // Trim because RTF may have some whitespace differences assert_eq!(recovered.trim(), original); } #[test] fn test_rtf_format_announced() { let formats = mime_to_rdp_formats(&["text/rtf"]); assert!(formats.iter().any(|f| f.id == CF_RTF)); assert!( formats .iter() .any(|f| f.name.as_ref().is_some_and(|n| n == "Rich Text Format")) ); } #[test] fn test_rtf_format_to_mime() { assert_eq!(rdp_format_to_mime(CF_RTF), Some("text/rtf")); } } lamco-clipboard-core-0.6.0/src/image.rs000064400000000000000000000766771046102023000160530ustar 00000000000000//! Image format conversion utilities. //! //! This module provides conversion between image formats commonly used in //! clipboard operations, particularly the Windows DIB (Device Independent Bitmap) //! format and standard image formats like PNG and JPEG. //! //! # Feature Flag //! //! This module requires the `image` feature: //! //! ```toml //! [dependencies] //! lamco-clipboard-core = { version = "0.1", features = ["image"] } //! ``` //! //! # Supported Conversions //! //! - PNG ↔ DIB (CF_DIB format 8, 40-byte header) //! - PNG ↔ DIBV5 (CF_DIBV5 format 17, 124-byte header with alpha) //! - JPEG ↔ DIB //! - JPEG ↔ DIBV5 //! - BMP ↔ DIB //! - GIF → PNG (read-only, converts to PNG for output) //! //! # DIB vs DIBV5 //! //! - **DIB (CF_DIB)**: Standard Windows bitmap, 40-byte header, no alpha support //! - **DIBV5 (CF_DIBV5)**: Extended bitmap, 124-byte header, full alpha and color space support //! //! Use DIBV5 for images with transparency. Modern Windows applications like //! Paint.NET and screenshot tools use DIBV5 to preserve alpha channels. use bytes::{BufMut, BytesMut}; use image::{DynamicImage, ImageFormat}; use crate::{ClipboardError, ClipboardResult}; /// Convert PNG image data to DIB (Device Independent Bitmap) format. /// /// DIB is the standard Windows bitmap format used in clipboard operations. /// This function decodes the PNG and creates a 32-bit BGRA DIB. /// /// # Example /// /// ```ignore /// use lamco_clipboard_core::image::png_to_dib; /// /// let png_data = std::fs::read("image.png")?; /// let dib_data = png_to_dib(&png_data)?; /// ``` pub fn png_to_dib(png_data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory_with_format(png_data, ImageFormat::Png) .map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dib_from_image(&image) } /// Convert JPEG image data to DIB format. pub fn jpeg_to_dib(jpeg_data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory_with_format(jpeg_data, ImageFormat::Jpeg) .map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dib_from_image(&image) } /// Convert GIF image data to DIB format. /// /// Note: GIF animations are not supported; only the first frame is converted. pub fn gif_to_dib(gif_data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory_with_format(gif_data, ImageFormat::Gif) .map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dib_from_image(&image) } /// Convert BMP file data to DIB format. /// /// BMP files have a 14-byte file header followed by the DIB data. /// This function extracts the DIB portion. pub fn bmp_to_dib(bmp_data: &[u8]) -> ClipboardResult> { if bmp_data.len() < 14 { return Err(ClipboardError::ImageDecode("BMP file too small".to_string())); } // Verify BMP signature if &bmp_data[0..2] != b"BM" { return Err(ClipboardError::ImageDecode("Invalid BMP signature".to_string())); } // DIB is everything after the 14-byte file header Ok(bmp_data[14..].to_vec()) } /// Convert DIB data to PNG format. /// /// This is the most common conversion for clipboard images going from /// Windows to Linux, as PNG is widely supported and lossless. pub fn dib_to_png(dib_data: &[u8]) -> ClipboardResult> { let image = parse_dib_to_image(dib_data)?; let mut png_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png) .map_err(|e| ClipboardError::ImageEncode(e.to_string()))?; Ok(png_data) } /// Convert DIB data to JPEG format. /// /// JPEG is lossy but produces smaller files. Use for photographs. pub fn dib_to_jpeg(dib_data: &[u8]) -> ClipboardResult> { let image = parse_dib_to_image(dib_data)?; let mut jpeg_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut jpeg_data), ImageFormat::Jpeg) .map_err(|e| ClipboardError::ImageEncode(e.to_string()))?; Ok(jpeg_data) } /// Convert DIB data to BMP file format. /// /// This adds the 14-byte BMP file header to the DIB data. pub fn dib_to_bmp(dib_data: &[u8]) -> ClipboardResult> { if dib_data.len() < 40 { return Err(ClipboardError::ImageDecode("DIB too small".to_string())); } // Parse DIB header to calculate file size let file_size = u32::try_from(14 + dib_data.len()).map_err(|_| ClipboardError::ImageDecode("DIB too large".to_string()))?; let pixel_offset: u32 = 14 + 40; // File header + DIB header (minimum) let mut bmp = BytesMut::new(); // BMP file header (14 bytes) bmp.put_slice(b"BM"); // Signature bmp.put_u32_le(file_size); // File size bmp.put_u16_le(0); // Reserved1 bmp.put_u16_le(0); // Reserved2 bmp.put_u32_le(pixel_offset); // Pixel data offset // Append DIB data bmp.put_slice(dib_data); Ok(bmp.to_vec()) } /// Convert any supported image format to DIB. /// /// Automatically detects the input format based on magic bytes. pub fn any_to_dib(data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory(data).map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dib_from_image(&image) } // ============================================================================= // DIBV5 Functions (CF_DIBV5 format 17) // ============================================================================= /// Convert PNG image data to DIBV5 format. /// /// DIBV5 is the extended Windows bitmap format that supports alpha channels /// and color space information. This creates an sRGB DIBV5 with a 124-byte /// BITMAPV5HEADER. /// /// # Example /// /// ```ignore /// use lamco_clipboard_core::image::png_to_dibv5; /// /// let png_data = std::fs::read("transparent.png")?; /// let dibv5_data = png_to_dibv5(&png_data)?; /// ``` pub fn png_to_dibv5(png_data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory_with_format(png_data, ImageFormat::Png) .map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dibv5_from_image(&image) } /// Convert JPEG image data to DIBV5 format. /// /// Note: JPEG doesn't support transparency, so the alpha channel will be 255. pub fn jpeg_to_dibv5(jpeg_data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory_with_format(jpeg_data, ImageFormat::Jpeg) .map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dibv5_from_image(&image) } /// Convert DIBV5 data to PNG format. /// /// This is the most common conversion for clipboard images going from /// Windows to Linux. PNG preserves the alpha channel from DIBV5. pub fn dibv5_to_png(dibv5_data: &[u8]) -> ClipboardResult> { let image = parse_dibv5_to_image(dibv5_data)?; let mut png_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png) .map_err(|e| ClipboardError::ImageEncode(e.to_string()))?; Ok(png_data) } /// Convert DIBV5 data to JPEG format. /// /// Note: JPEG is lossy and doesn't support transparency. /// Use `dibv5_to_png` to preserve alpha. pub fn dibv5_to_jpeg(dibv5_data: &[u8]) -> ClipboardResult> { let image = parse_dibv5_to_image(dibv5_data)?; let mut jpeg_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut jpeg_data), ImageFormat::Jpeg) .map_err(|e| ClipboardError::ImageEncode(e.to_string()))?; Ok(jpeg_data) } /// Convert any supported image format to DIBV5. /// /// Automatically detects the input format based on magic bytes. /// Use DIBV5 when transparency preservation is important. pub fn any_to_dibv5(data: &[u8]) -> ClipboardResult> { let image = image::load_from_memory(data).map_err(|e| ClipboardError::ImageDecode(e.to_string()))?; create_dibv5_from_image(&image) } /// Check if image data has any transparent pixels. /// /// Returns `true` if any pixel has alpha < 255. /// Use this to decide whether to use DIB or DIBV5 format. pub fn has_transparency(image_data: &[u8]) -> bool { if let Ok(img) = image::load_from_memory(image_data) { let rgba = img.to_rgba8(); rgba.pixels().any(|p| p[3] != 255) } else { false } } /// Get image dimensions from DIB data without full decode. /// /// Returns (width, height) in pixels. pub fn dib_dimensions(dib_data: &[u8]) -> ClipboardResult<(u32, u32)> { if dib_data.len() < 12 { return Err(ClipboardError::ImageDecode("DIB too small".to_string())); } let width = i32::from_le_bytes([dib_data[4], dib_data[5], dib_data[6], dib_data[7]]).unsigned_abs(); let height = i32::from_le_bytes([dib_data[8], dib_data[9], dib_data[10], dib_data[11]]).unsigned_abs(); Ok((width, height)) } // ============================================================================= // Internal Functions // ============================================================================= /// Create DIB data from a DynamicImage. fn create_dib_from_image(image: &DynamicImage) -> ClipboardResult> { let rgba = image.to_rgba8(); let (width, height) = (rgba.width(), rgba.height()); let mut dib = BytesMut::new(); // BITMAPINFOHEADER structure (40 bytes) dib.put_u32_le(40); // biSize dib.put_i32_le(i32::try_from(width).unwrap_or(i32::MAX)); // biWidth dib.put_i32_le(-i32::try_from(height).unwrap_or(i32::MAX)); // biHeight (negative for top-down) dib.put_u16_le(1); // biPlanes dib.put_u16_le(32); // biBitCount (32 bits for BGRA) dib.put_u32_le(0); // biCompression (BI_RGB = 0) let image_size = width.saturating_mul(height).saturating_mul(4); dib.put_u32_le(image_size); // biSizeImage dib.put_i32_le(0); // biXPelsPerMeter dib.put_i32_le(0); // biYPelsPerMeter dib.put_u32_le(0); // biClrUsed dib.put_u32_le(0); // biClrImportant // Pixel data (convert RGBA to BGRA - Windows byte order) for pixel in rgba.pixels() { dib.put_u8(pixel[2]); // Blue dib.put_u8(pixel[1]); // Green dib.put_u8(pixel[0]); // Red dib.put_u8(pixel[3]); // Alpha } Ok(dib.to_vec()) } /// Parse DIB data into a DynamicImage. fn parse_dib_to_image(dib_data: &[u8]) -> ClipboardResult { if dib_data.len() < 40 { return Err(ClipboardError::ImageDecode("DIB too small".to_string())); } // Parse BITMAPINFOHEADER let bi_size = u32::from_le_bytes([dib_data[0], dib_data[1], dib_data[2], dib_data[3]]); if bi_size < 40 { return Err(ClipboardError::ImageDecode("Invalid DIB header size".to_string())); } let width = i32::from_le_bytes([dib_data[4], dib_data[5], dib_data[6], dib_data[7]]).unsigned_abs(); let height_raw = i32::from_le_bytes([dib_data[8], dib_data[9], dib_data[10], dib_data[11]]); let height = height_raw.unsigned_abs(); let top_down = height_raw < 0; let bit_count = u16::from_le_bytes([dib_data[14], dib_data[15]]); let header_size = bi_size as usize; if header_size >= dib_data.len() { return Err(ClipboardError::ImageDecode("DIB header larger than data".to_string())); } let pixel_data = &dib_data[header_size..]; // Convert based on bit depth let image = match bit_count { 32 => convert_32bit_dib(pixel_data, width, height, top_down)?, 24 => convert_24bit_dib(pixel_data, width, height, top_down)?, _ => { return Err(ClipboardError::ImageDecode(format!( "Unsupported DIB bit depth: {}", bit_count ))); } }; Ok(image) } /// Convert 32-bit BGRA DIB to RGBA image. fn convert_32bit_dib(pixel_data: &[u8], width: u32, height: u32, top_down: bool) -> ClipboardResult { let expected_size = (width as usize) * (height as usize) * 4; if pixel_data.len() < expected_size { return Err(ClipboardError::ImageDecode(format!( "Insufficient pixel data: {} < {}", pixel_data.len(), expected_size ))); } let mut rgba_data = Vec::with_capacity(expected_size); for y in 0..height { let row_y = if top_down { y } else { height - 1 - y }; let row_offset = (row_y as usize) * (width as usize) * 4; for x in 0..width { let pixel_offset = row_offset + (x as usize) * 4; if pixel_offset + 3 < pixel_data.len() { rgba_data.push(pixel_data[pixel_offset + 2]); // Red rgba_data.push(pixel_data[pixel_offset + 1]); // Green rgba_data.push(pixel_data[pixel_offset]); // Blue rgba_data.push(pixel_data[pixel_offset + 3]); // Alpha } } } image::RgbaImage::from_raw(width, height, rgba_data) .map(DynamicImage::ImageRgba8) .ok_or_else(|| ClipboardError::ImageDecode("Failed to create image from DIB".to_string())) } /// Convert 24-bit BGR DIB to RGB image. fn convert_24bit_dib(pixel_data: &[u8], width: u32, height: u32, top_down: bool) -> ClipboardResult { // 24-bit DIB rows are aligned to 4-byte boundaries let row_size = (width * 3).div_ceil(4) * 4; let expected_size = (row_size as usize) * (height as usize); if pixel_data.len() < expected_size { return Err(ClipboardError::ImageDecode(format!( "Insufficient pixel data: {} < {}", pixel_data.len(), expected_size ))); } let mut rgb_data = Vec::with_capacity((width as usize) * (height as usize) * 3); for y in 0..height { let row_y = if top_down { y } else { height - 1 - y }; let row_offset = (row_y as usize) * (row_size as usize); for x in 0..width { let pixel_offset = row_offset + (x as usize) * 3; if pixel_offset + 2 < pixel_data.len() { rgb_data.push(pixel_data[pixel_offset + 2]); // Red rgb_data.push(pixel_data[pixel_offset + 1]); // Green rgb_data.push(pixel_data[pixel_offset]); // Blue } } } image::RgbImage::from_raw(width, height, rgb_data) .map(DynamicImage::ImageRgb8) .ok_or_else(|| ClipboardError::ImageDecode("Failed to create image from DIB".to_string())) } // ============================================================================= // DIBV5 Internal Functions // ============================================================================= /// BITMAPV5HEADER size in bytes. const DIBV5_HEADER_SIZE: usize = 124; /// LCS_sRGB color space type ("sRGB" in little-endian ASCII). const LCS_SRGB: u32 = 0x7352_4742; /// LCS_GM_IMAGES rendering intent (perceptual). const LCS_GM_IMAGES: u32 = 2; /// Create DIBV5 data from a DynamicImage. /// /// Creates a 124-byte BITMAPV5HEADER with: /// - BI_BITFIELDS compression (masks for BGRA) /// - sRGB color space /// - Full alpha channel support fn create_dibv5_from_image(image: &DynamicImage) -> ClipboardResult> { let rgba = image.to_rgba8(); let (width, height) = (rgba.width(), rgba.height()); // Pre-calculate sizes let image_size = width.saturating_mul(height).saturating_mul(4); let total_size = DIBV5_HEADER_SIZE + (image_size as usize); let mut dib = BytesMut::with_capacity(total_size); // BITMAPV5HEADER structure (124 bytes) // Offsets 0-3: Header size dib.put_u32_le(DIBV5_HEADER_SIZE as u32); // bV5Size // Offsets 4-7: Width dib.put_i32_le(i32::try_from(width).unwrap_or(i32::MAX)); // bV5Width // Offsets 8-11: Height (negative = top-down bitmap) dib.put_i32_le(-i32::try_from(height).unwrap_or(i32::MAX)); // bV5Height // Offsets 12-13: Planes (always 1) dib.put_u16_le(1); // bV5Planes // Offsets 14-15: Bit count (32-bit BGRA) dib.put_u16_le(32); // bV5BitCount // Offsets 16-19: Compression (BI_BITFIELDS = 3) dib.put_u32_le(3); // bV5Compression // Offsets 20-23: Image size dib.put_u32_le(image_size); // bV5SizeImage // Offsets 24-27: X pixels per meter (0 = undefined) dib.put_i32_le(0); // bV5XPelsPerMeter // Offsets 28-31: Y pixels per meter (0 = undefined) dib.put_i32_le(0); // bV5YPelsPerMeter // Offsets 32-35: Colors used (0 = all) dib.put_u32_le(0); // bV5ClrUsed // Offsets 36-39: Colors important (0 = all) dib.put_u32_le(0); // bV5ClrImportant // Offsets 40-43: Red channel mask (byte 2 in BGRA) dib.put_u32_le(0x00FF_0000); // bV5RedMask // Offsets 44-47: Green channel mask (byte 1 in BGRA) dib.put_u32_le(0x0000_FF00); // bV5GreenMask // Offsets 48-51: Blue channel mask (byte 0 in BGRA) dib.put_u32_le(0x0000_00FF); // bV5BlueMask // Offsets 52-55: Alpha channel mask (byte 3 in BGRA) dib.put_u32_le(0xFF00_0000); // bV5AlphaMask // Offsets 56-59: Color space type (sRGB) dib.put_u32_le(LCS_SRGB); // bV5CSType // Offsets 60-95: CIEXYZTRIPLE endpoints (36 bytes, zeros for sRGB) for _ in 0..9 { dib.put_u32_le(0); } // Offsets 96-99: Gamma red (0 = use sRGB default) dib.put_u32_le(0); // bV5GammaRed // Offsets 100-103: Gamma green dib.put_u32_le(0); // bV5GammaGreen // Offsets 104-107: Gamma blue dib.put_u32_le(0); // bV5GammaBlue // Offsets 108-111: Rendering intent dib.put_u32_le(LCS_GM_IMAGES); // bV5Intent // Offsets 112-115: ICC profile data offset (0 = none) dib.put_u32_le(0); // bV5ProfileData // Offsets 116-119: ICC profile size (0 = none) dib.put_u32_le(0); // bV5ProfileSize // Offsets 120-123: Reserved dib.put_u32_le(0); // bV5Reserved debug_assert_eq!(dib.len(), DIBV5_HEADER_SIZE); // Pixel data: convert RGBA to BGRA (Windows byte order) for pixel in rgba.pixels() { dib.put_u8(pixel[2]); // Blue dib.put_u8(pixel[1]); // Green dib.put_u8(pixel[0]); // Red dib.put_u8(pixel[3]); // Alpha } Ok(dib.to_vec()) } /// Parse DIBV5 data into a DynamicImage. /// /// Handles both standard 124-byte DIBV5 headers and the "short DIBV5" bug /// where some applications use a 40-byte header with format ID 17. fn parse_dibv5_to_image(dibv5_data: &[u8]) -> ClipboardResult { if dibv5_data.len() < 4 { return Err(ClipboardError::ImageDecode("DIBV5 too small".to_string())); } // Read header size to determine format variant let header_size = u32::from_le_bytes([dibv5_data[0], dibv5_data[1], dibv5_data[2], dibv5_data[3]]); match header_size { 40 => { // "Short DIBV5" - some apps use CF_DIBV5 format ID but DIB header // Fall back to regular DIB parser parse_dib_to_image(dibv5_data) } 124 => { // Standard DIBV5 with full 124-byte header parse_full_dibv5(dibv5_data) } _ => Err(ClipboardError::ImageDecode(format!( "Invalid DIBV5 header size: {} (expected 40 or 124)", header_size ))), } } /// Parse standard 124-byte DIBV5 data. fn parse_full_dibv5(data: &[u8]) -> ClipboardResult { if data.len() < DIBV5_HEADER_SIZE { return Err(ClipboardError::ImageDecode( "DIBV5 data too small for header".to_string(), )); } // Parse dimensions let width = i32::from_le_bytes([data[4], data[5], data[6], data[7]]).unsigned_abs(); let height_raw = i32::from_le_bytes([data[8], data[9], data[10], data[11]]); let height = height_raw.unsigned_abs(); let top_down = height_raw < 0; // Parse bit depth and compression let bit_count = u16::from_le_bytes([data[14], data[15]]); let compression = u32::from_le_bytes([data[16], data[17], data[18], data[19]]); // Parse color masks for BI_BITFIELDS (compression == 3) let (red_mask, green_mask, blue_mask, alpha_mask) = if compression == 3 { ( u32::from_le_bytes([data[40], data[41], data[42], data[43]]), u32::from_le_bytes([data[44], data[45], data[46], data[47]]), u32::from_le_bytes([data[48], data[49], data[50], data[51]]), u32::from_le_bytes([data[52], data[53], data[54], data[55]]), ) } else { // Default BGRA masks for BI_RGB (0x00FF_0000, 0x0000_FF00, 0x0000_00FF, 0xFF00_0000) }; // Pixel data starts after 124-byte header let pixel_data = &data[DIBV5_HEADER_SIZE..]; match bit_count { 32 => convert_32bit_dibv5( pixel_data, width, height, top_down, red_mask, green_mask, blue_mask, alpha_mask, ), 24 => convert_24bit_dib(pixel_data, width, height, top_down), _ => Err(ClipboardError::ImageDecode(format!( "Unsupported DIBV5 bit depth: {}", bit_count ))), } } /// Convert 32-bit DIBV5 pixel data using color masks. #[allow(clippy::too_many_arguments)] fn convert_32bit_dibv5( pixel_data: &[u8], width: u32, height: u32, top_down: bool, red_mask: u32, green_mask: u32, blue_mask: u32, alpha_mask: u32, ) -> ClipboardResult { let expected_size = (width as usize) * (height as usize) * 4; if pixel_data.len() < expected_size { return Err(ClipboardError::ImageDecode(format!( "Insufficient DIBV5 pixel data: {} < {}", pixel_data.len(), expected_size ))); } // Calculate shifts for each color channel let red_shift = red_mask.trailing_zeros(); let green_shift = green_mask.trailing_zeros(); let blue_shift = blue_mask.trailing_zeros(); let alpha_shift = alpha_mask.trailing_zeros(); let mut rgba_data = Vec::with_capacity(expected_size); for y in 0..height { let row_y = if top_down { y } else { height - 1 - y }; let row_offset = (row_y as usize) * (width as usize) * 4; for x in 0..width { let pixel_offset = row_offset + (x as usize) * 4; if pixel_offset + 3 < pixel_data.len() { let pixel = u32::from_le_bytes([ pixel_data[pixel_offset], pixel_data[pixel_offset + 1], pixel_data[pixel_offset + 2], pixel_data[pixel_offset + 3], ]); // Extract channels using masks and shifts let red = ((pixel & red_mask) >> red_shift) as u8; let green = ((pixel & green_mask) >> green_shift) as u8; let blue = ((pixel & blue_mask) >> blue_shift) as u8; let alpha = if alpha_mask != 0 { ((pixel & alpha_mask) >> alpha_shift) as u8 } else { 255 // No alpha channel, assume opaque }; rgba_data.push(red); rgba_data.push(green); rgba_data.push(blue); rgba_data.push(alpha); } } } image::RgbaImage::from_raw(width, height, rgba_data) .map(DynamicImage::ImageRgba8) .ok_or_else(|| ClipboardError::ImageDecode("Failed to create image from DIBV5".to_string())) } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_and_parse_dib() { // Create a small test image (10x10 red square) let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(10, 10, image::Rgba([255, 0, 0, 255]))); // Convert to DIB let dib = create_dib_from_image(&image).unwrap(); // Verify DIB header assert!(dib.len() >= 40); assert_eq!(u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]), 40); // biSize // Convert back to image let parsed = parse_dib_to_image(&dib).unwrap(); assert_eq!(parsed.width(), 10); assert_eq!(parsed.height(), 10); } #[test] fn test_dib_dimensions() { let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 50, image::Rgba([0, 0, 0, 255]))); let dib = create_dib_from_image(&image).unwrap(); let (width, height) = dib_dimensions(&dib).unwrap(); assert_eq!(width, 100); assert_eq!(height, 50); } #[test] fn test_png_roundtrip() { // Create a small PNG let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(5, 5, image::Rgba([100, 150, 200, 255]))); let mut png_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png) .unwrap(); // PNG → DIB → PNG let dib = png_to_dib(&png_data).unwrap(); let png_back = dib_to_png(&dib).unwrap(); // Load and verify let loaded = image::load_from_memory(&png_back).unwrap(); assert_eq!(loaded.width(), 5); assert_eq!(loaded.height(), 5); } #[test] fn test_bmp_roundtrip() { let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([50, 100, 150, 255]))); let dib = create_dib_from_image(&image).unwrap(); // DIB → BMP → DIB let bmp = dib_to_bmp(&dib).unwrap(); // Verify BMP signature assert_eq!(&bmp[0..2], b"BM"); // Extract DIB back let dib_back = bmp_to_dib(&bmp).unwrap(); assert_eq!(dib, dib_back); } #[test] fn test_invalid_dib() { // Too small assert!(parse_dib_to_image(&[0; 30]).is_err()); // Invalid header size let mut invalid_dib = vec![0; 50]; invalid_dib[0] = 10; // Invalid biSize < 40 assert!(parse_dib_to_image(&invalid_dib).is_err()); } #[test] fn test_invalid_bmp() { // Too small assert!(bmp_to_dib(&[0; 10]).is_err()); // Invalid signature let mut invalid_bmp = vec![0; 20]; invalid_bmp[0] = b'X'; invalid_bmp[1] = b'Y'; assert!(bmp_to_dib(&invalid_bmp).is_err()); } // ========================================================================= // DIBV5 Tests // ========================================================================= #[test] fn test_create_and_parse_dibv5() { // Create a small test image (10x10 red with 50% transparency) let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(10, 10, image::Rgba([255, 0, 0, 128]))); // Convert to DIBV5 let dibv5 = create_dibv5_from_image(&image).unwrap(); // Verify DIBV5 header assert!(dibv5.len() >= 124); assert_eq!(u32::from_le_bytes([dibv5[0], dibv5[1], dibv5[2], dibv5[3]]), 124); // bV5Size // Convert back to image let parsed = parse_dibv5_to_image(&dibv5).unwrap(); assert_eq!(parsed.width(), 10); assert_eq!(parsed.height(), 10); // Verify alpha channel preserved let rgba = parsed.to_rgba8(); let pixel = rgba.get_pixel(0, 0); assert_eq!(pixel[3], 128); // Alpha should be preserved } #[test] fn test_dibv5_header_structure() { let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(4, 4, image::Rgba([100, 150, 200, 128]))); let dibv5 = create_dibv5_from_image(&image).unwrap(); // Verify header fields assert_eq!(u32::from_le_bytes([dibv5[0], dibv5[1], dibv5[2], dibv5[3]]), 124); // Size assert_eq!(i32::from_le_bytes([dibv5[4], dibv5[5], dibv5[6], dibv5[7]]), 4); // Width assert_eq!(i32::from_le_bytes([dibv5[8], dibv5[9], dibv5[10], dibv5[11]]), -4); // Height (negative = top-down) assert_eq!(u16::from_le_bytes([dibv5[14], dibv5[15]]), 32); // Bit count assert_eq!(u32::from_le_bytes([dibv5[16], dibv5[17], dibv5[18], dibv5[19]]), 3); // BI_BITFIELDS // Color masks assert_eq!( u32::from_le_bytes([dibv5[40], dibv5[41], dibv5[42], dibv5[43]]), 0x00FF0000 ); // Red assert_eq!( u32::from_le_bytes([dibv5[44], dibv5[45], dibv5[46], dibv5[47]]), 0x0000FF00 ); // Green assert_eq!( u32::from_le_bytes([dibv5[48], dibv5[49], dibv5[50], dibv5[51]]), 0x000000FF ); // Blue assert_eq!( u32::from_le_bytes([dibv5[52], dibv5[53], dibv5[54], dibv5[55]]), 0xFF000000 ); // Alpha // Color space assert_eq!( u32::from_le_bytes([dibv5[56], dibv5[57], dibv5[58], dibv5[59]]), LCS_SRGB ); } #[test] fn test_png_to_dibv5_roundtrip() { // Create PNG with transparency let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(5, 5, image::Rgba([50, 100, 150, 100]))); let mut png_data = Vec::new(); image .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png) .unwrap(); // PNG → DIBV5 → PNG let dibv5 = png_to_dibv5(&png_data).unwrap(); let png_back = dibv5_to_png(&dibv5).unwrap(); // Load and verify let loaded = image::load_from_memory(&png_back).unwrap(); assert_eq!(loaded.width(), 5); assert_eq!(loaded.height(), 5); // Verify alpha preserved let rgba = loaded.to_rgba8(); let pixel = rgba.get_pixel(0, 0); assert_eq!(pixel[3], 100); } #[test] fn test_has_transparency() { // Image with transparency let transparent = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 128]))); let mut transparent_png = Vec::new(); transparent .write_to(&mut std::io::Cursor::new(&mut transparent_png), ImageFormat::Png) .unwrap(); assert!(has_transparency(&transparent_png)); // Opaque image let opaque = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255]))); let mut opaque_png = Vec::new(); opaque .write_to(&mut std::io::Cursor::new(&mut opaque_png), ImageFormat::Png) .unwrap(); assert!(!has_transparency(&opaque_png)); } #[test] fn test_short_dibv5_fallback() { // Create a "short DIBV5" (40-byte header with format 17) // This tests the compatibility fallback let image = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(3, 3, image::Rgba([255, 128, 64, 255]))); // Create DIB (40-byte header) let dib = create_dib_from_image(&image).unwrap(); assert_eq!(u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]), 40); // Parse as DIBV5 should fall back to DIB parser let parsed = parse_dibv5_to_image(&dib).unwrap(); assert_eq!(parsed.width(), 3); assert_eq!(parsed.height(), 3); } #[test] fn test_invalid_dibv5() { // Too small assert!(parse_dibv5_to_image(&[0; 3]).is_err()); // Invalid header size let mut invalid = vec![0; 150]; invalid[0] = 50; // Invalid size (not 40 or 124) assert!(parse_dibv5_to_image(&invalid).is_err()); } #[test] fn test_dibv5_pixel_colors() { // Create image with specific colors let mut img = image::RgbaImage::new(2, 2); img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); // Red img.put_pixel(1, 0, image::Rgba([0, 255, 0, 128])); // Green semi-transparent img.put_pixel(0, 1, image::Rgba([0, 0, 255, 64])); // Blue mostly transparent img.put_pixel(1, 1, image::Rgba([128, 128, 128, 0])); // Gray fully transparent let image = DynamicImage::ImageRgba8(img); // Round-trip through DIBV5 let dibv5 = create_dibv5_from_image(&image).unwrap(); let parsed = parse_dibv5_to_image(&dibv5).unwrap(); let rgba = parsed.to_rgba8(); // Verify colors assert_eq!(rgba.get_pixel(0, 0), &image::Rgba([255, 0, 0, 255])); assert_eq!(rgba.get_pixel(1, 0), &image::Rgba([0, 255, 0, 128])); assert_eq!(rgba.get_pixel(0, 1), &image::Rgba([0, 0, 255, 64])); assert_eq!(rgba.get_pixel(1, 1), &image::Rgba([128, 128, 128, 0])); } } lamco-clipboard-core-0.6.0/src/lib.rs000064400000000000000000000041471046102023000155160ustar 00000000000000//! # lamco-clipboard-core //! //! Clipboard format conversion and synchronization utilities for Rust. //! //! This crate provides core clipboard infrastructure that works independently //! of any specific remote desktop protocol: //! //! - **[`FormatConverter`]** - MIME type and Windows clipboard format conversion //! - **[`LoopDetector`]** - Prevent clipboard sync loops with content hashing //! - **[`TransferEngine`]** - Chunked transfer for large clipboard data //! - **[`sanitize`]** - Cross-platform filename sanitization and file URI parsing //! //! ## Quick Start //! //! ```rust //! use lamco_clipboard_core::{FormatConverter, LoopDetector}; //! use lamco_clipboard_core::formats::{ClipboardFormat, mime_to_rdp_formats}; //! //! // Convert MIME types to clipboard formats //! let formats = mime_to_rdp_formats(&["text/plain", "text/html"]); //! //! // Check for clipboard loops //! let mut detector = LoopDetector::new(); //! if !detector.would_cause_loop(&formats) { //! // Safe to sync //! } //! ``` //! //! ## Feature Flags //! //! - `image` - Enable image format conversion (PNG, JPEG, BMP to Windows DIB) //! //! ## Architecture //! //! This crate handles format conversion, loop detection, and transfer mechanics. //! For the clipboard backend trait (`ClipboardSink`) and RDP-specific file //! transfer types, see [`lamco-rdp-clipboard`](https://crates.io/crates/lamco-rdp-clipboard). #![cfg_attr(docsrs, feature(doc_cfg))] #![deny(missing_docs)] mod error; mod transfer; pub mod formats; pub mod loop_detector; pub mod sanitize; #[cfg(feature = "image")] pub mod image; pub use error::{ClipboardError, ClipboardResult}; pub use formats::{ClipboardFormat, FormatConverter}; pub use loop_detector::{ClipboardSource, LoopDetectionConfig, LoopDetector}; pub use transfer::{ DEFAULT_CHUNK_SIZE, DEFAULT_MAX_SIZE, DEFAULT_TIMEOUT_MS, TransferConfig, TransferEngine, TransferProgress, TransferState, }; /// Prelude module for convenient imports pub mod prelude { pub use crate::formats::{mime_to_rdp_formats, rdp_format_to_mime}; pub use crate::{ClipboardError, ClipboardResult, FormatConverter, LoopDetector}; } lamco-clipboard-core-0.6.0/src/loop_detector.rs000064400000000000000000000435401046102023000176120ustar 00000000000000//! Loop detection for clipboard synchronization. //! //! Prevents clipboard sync loops by tracking format and content hashes. use sha2::{Digest, Sha256}; use std::collections::VecDeque; use std::time::{Duration, Instant}; use crate::ClipboardFormat; /// Configuration for loop detection #[derive(Debug, Clone)] pub struct LoopDetectionConfig { /// Time window for detecting loops (default: 500ms) pub window_ms: u64, /// Maximum number of operations to track pub max_history: usize, /// Enable content hashing for deduplication pub enable_content_hashing: bool, /// Optional rate limit in milliseconds (default: None) /// /// When set, sync operations are throttled to at most one per `rate_limit_ms`. /// This provides belt-and-suspenders protection against rapid clipboard updates /// even when loop detection passes. pub rate_limit_ms: Option, } impl Default for LoopDetectionConfig { fn default() -> Self { Self { window_ms: 500, max_history: 10, enable_content_hashing: true, rate_limit_ms: None, } } } impl LoopDetectionConfig { /// Create config with rate limiting enabled /// /// # Example /// /// ```rust /// use lamco_clipboard_core::LoopDetectionConfig; /// /// let config = LoopDetectionConfig::with_rate_limit(200); /// assert_eq!(config.rate_limit_ms, Some(200)); /// ``` pub fn with_rate_limit(rate_limit_ms: u64) -> Self { Self { rate_limit_ms: Some(rate_limit_ms), ..Default::default() } } } /// Source of a clipboard operation #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClipboardSource { /// Operation from a remote clipboard (RDP, VNC, etc.) Remote, /// Operation from local clipboard (Portal, X11, etc.) Local, } impl ClipboardSource { /// Get the opposite source pub fn opposite(self) -> Self { match self { Self::Remote => Self::Local, Self::Local => Self::Remote, } } } /// A recorded clipboard operation for loop detection #[derive(Debug, Clone)] struct ClipboardOperation { /// Hash of the operation (formats or content) hash: String, /// Source of the operation source: ClipboardSource, /// When the operation occurred timestamp: Instant, } /// Detects and prevents clipboard synchronization loops. /// /// # How It Works /// /// When clipboard content is copied, the same content often triggers events /// on both ends (RDP and local). Without loop detection, this causes: /// /// 1. User copies on remote → Remote sends to local /// 2. Local clipboard updates → Signal sent to sync back /// 3. Remote clipboard updates → Remote sends to local again /// 4. ... infinite loop /// /// The `LoopDetector` prevents this by: /// /// 1. **Format hashing**: Hashes the list of formats/MIME types /// 2. **Content hashing**: Hashes actual clipboard content (optional) /// 3. **Time windowing**: Only detects loops within a configurable time window /// 4. **Source tracking**: Distinguishes remote vs local operations /// 5. **Rate limiting**: Optional throttle to prevent rapid sync storms /// /// # Example /// /// ```rust /// use lamco_clipboard_core::{LoopDetector, ClipboardFormat}; /// use lamco_clipboard_core::loop_detector::ClipboardSource; /// /// let mut detector = LoopDetector::new(); /// /// // Record an RDP operation /// let formats = vec![ClipboardFormat::unicode_text()]; /// detector.record_formats(&formats, ClipboardSource::Remote); /// /// // Check if a local operation would cause a loop /// if detector.would_cause_loop(&formats) { /// println!("Loop detected, skipping sync"); /// } /// ``` #[derive(Debug)] pub struct LoopDetector { /// Configuration config: LoopDetectionConfig, /// Recent format operations format_history: VecDeque, /// Recent content hashes content_history: VecDeque, /// Last sync time for rate limiting (per source) last_sync_remote: Option, last_sync_local: Option, } impl Default for LoopDetector { fn default() -> Self { Self::new() } } impl LoopDetector { /// Create a new loop detector with default configuration pub fn new() -> Self { Self::with_config(LoopDetectionConfig::default()) } /// Create a new loop detector with custom configuration pub fn with_config(config: LoopDetectionConfig) -> Self { Self { config, format_history: VecDeque::new(), content_history: VecDeque::new(), last_sync_remote: None, last_sync_local: None, } } /// Record a format list operation pub fn record_formats(&mut self, formats: &[ClipboardFormat], source: ClipboardSource) { let hash = Self::hash_formats(formats); self.record_operation(&mut self.format_history.clone(), hash, source); // Need to work around borrow checker let hash = Self::hash_formats(formats); self.format_history.push_back(ClipboardOperation { hash, source, timestamp: Instant::now(), }); self.cleanup_history(); } /// Record a MIME type list operation pub fn record_mime_types(&mut self, mime_types: &[String], source: ClipboardSource) { let hash = Self::hash_mime_types(mime_types); self.format_history.push_back(ClipboardOperation { hash, source, timestamp: Instant::now(), }); self.cleanup_history(); } /// Record content data for deduplication pub fn record_content(&mut self, data: &[u8], source: ClipboardSource) { if !self.config.enable_content_hashing { return; } let hash = Self::hash_content(data); self.content_history.push_back(ClipboardOperation { hash, source, timestamp: Instant::now(), }); self.cleanup_history(); } /// Check if syncing these formats would cause a loop /// /// Returns true if a recent operation from the opposite source /// had the same format hash. pub fn would_cause_loop(&self, formats: &[ClipboardFormat]) -> bool { let hash = Self::hash_formats(formats); self.check_hash_collision(&self.format_history, &hash, ClipboardSource::Local) } /// Check if syncing these MIME types would cause a loop pub fn would_cause_loop_mime(&self, mime_types: &[String]) -> bool { let hash = Self::hash_mime_types(mime_types); self.check_hash_collision(&self.format_history, &hash, ClipboardSource::Remote) } /// Check if this content would cause a loop pub fn would_cause_content_loop(&self, data: &[u8], source: ClipboardSource) -> bool { if !self.config.enable_content_hashing { return false; } let hash = Self::hash_content(data); self.check_hash_collision(&self.content_history, &hash, source) } /// Compute hash for deduplication of arbitrary data pub fn compute_hash(data: &[u8]) -> String { Self::hash_content(data) } /// Clear all history pub fn clear(&mut self) { self.format_history.clear(); self.content_history.clear(); self.last_sync_remote = None; self.last_sync_local = None; } /// Check if sync is rate limited for the given source /// /// Returns true if a sync was performed too recently and should be skipped. /// This is only active when `rate_limit_ms` is configured. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::{LoopDetector, LoopDetectionConfig}; /// use lamco_clipboard_core::loop_detector::ClipboardSource; /// /// let config = LoopDetectionConfig::with_rate_limit(200); /// let mut detector = LoopDetector::with_config(config); /// /// // First sync is not rate limited /// assert!(!detector.is_rate_limited(ClipboardSource::Remote)); /// /// // Record that we synced /// detector.record_sync(ClipboardSource::Remote); /// /// // Immediate second sync would be rate limited /// assert!(detector.is_rate_limited(ClipboardSource::Remote)); /// ``` pub fn is_rate_limited(&self, source: ClipboardSource) -> bool { let Some(rate_limit_ms) = self.config.rate_limit_ms else { return false; }; let last_sync = match source { ClipboardSource::Remote => self.last_sync_remote, ClipboardSource::Local => self.last_sync_local, }; let Some(last) = last_sync else { return false; }; let elapsed = last.elapsed(); elapsed < Duration::from_millis(rate_limit_ms) } /// Record that a sync operation was performed /// /// Call this after successfully syncing clipboard data to update /// the rate limiting timestamp. pub fn record_sync(&mut self, source: ClipboardSource) { let now = Instant::now(); match source { ClipboardSource::Remote => self.last_sync_remote = Some(now), ClipboardSource::Local => self.last_sync_local = Some(now), } } /// Combined check: would cause loop OR is rate limited /// /// Convenience method that checks both conditions. Returns true if /// the sync should be skipped for any reason. pub fn should_skip_sync(&self, formats: &[ClipboardFormat], source: ClipboardSource) -> bool { if self.is_rate_limited(source) { tracing::debug!("Sync skipped: rate limited for {:?}", source); return true; } let hash = Self::hash_formats(formats); let would_loop = self.check_hash_collision(&self.format_history, &hash, source); if would_loop { tracing::debug!("Sync skipped: would cause loop"); } would_loop } /// Combined check for MIME types: would cause loop OR is rate limited pub fn should_skip_sync_mime(&self, mime_types: &[String], source: ClipboardSource) -> bool { if self.is_rate_limited(source) { tracing::debug!("Sync skipped: rate limited for {:?}", source); return true; } let hash = Self::hash_mime_types(mime_types); let would_loop = self.check_hash_collision(&self.format_history, &hash, source); if would_loop { tracing::debug!("Sync skipped: would cause loop"); } would_loop } // ========================================================================= // Private Methods // ========================================================================= fn check_hash_collision( &self, history: &VecDeque, hash: &str, current_source: ClipboardSource, ) -> bool { let window = Duration::from_millis(self.config.window_ms); let now = Instant::now(); for op in history.iter().rev() { // Only check recent operations if now.duration_since(op.timestamp) > window { break; } // Only detect loops from the opposite source if op.source == current_source.opposite() && op.hash == hash { return true; } } false } fn record_operation(&mut self, history: &mut VecDeque, hash: String, source: ClipboardSource) { history.push_back(ClipboardOperation { hash, source, timestamp: Instant::now(), }); } fn cleanup_history(&mut self) { let window = Duration::from_millis(self.config.window_ms * 2); let now = Instant::now(); // Remove old entries while let Some(front) = self.format_history.front() { if now.duration_since(front.timestamp) > window { self.format_history.pop_front(); } else { break; } } while let Some(front) = self.content_history.front() { if now.duration_since(front.timestamp) > window { self.content_history.pop_front(); } else { break; } } // Enforce max history size while self.format_history.len() > self.config.max_history { self.format_history.pop_front(); } while self.content_history.len() > self.config.max_history { self.content_history.pop_front(); } } fn hash_formats(formats: &[ClipboardFormat]) -> String { let mut hasher = Sha256::new(); for format in formats { hasher.update(format.id.to_le_bytes()); if let Some(name) = &format.name { hasher.update(name.as_bytes()); } } format!("{:x}", hasher.finalize()) } fn hash_mime_types(mime_types: &[String]) -> String { let mut hasher = Sha256::new(); for mime in mime_types { hasher.update(mime.as_bytes()); hasher.update(b"\0"); } format!("{:x}", hasher.finalize()) } fn hash_content(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); format!("{:x}", hasher.finalize()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_no_loop_different_formats() { let mut detector = LoopDetector::new(); let formats1 = vec![ClipboardFormat::unicode_text()]; let formats2 = vec![ClipboardFormat::html()]; detector.record_formats(&formats1, ClipboardSource::Remote); assert!(!detector.would_cause_loop(&formats2)); } #[test] fn test_loop_same_formats() { let mut detector = LoopDetector::new(); let formats = vec![ClipboardFormat::unicode_text()]; detector.record_formats(&formats, ClipboardSource::Remote); assert!(detector.would_cause_loop(&formats)); } #[test] fn test_no_loop_same_source() { let mut detector = LoopDetector::new(); let formats = vec![ClipboardFormat::unicode_text()]; // Record from Local detector.record_formats(&formats, ClipboardSource::Local); // Check would_cause_loop checks against RDP source, so same formats from Local // shouldn't trigger (opposite source check) // Actually would_cause_loop always checks against Local source // So this should NOT trigger because we recorded from Local, checking Local // Hmm, the check is: op.source == current_source.opposite() // would_cause_loop uses ClipboardSource::Local as current_source // So it checks if op.source == Local.opposite() == Remote // We recorded from Local, so op.source == Local != Remote // So this should NOT detect a loop - correct! assert!(!detector.would_cause_loop(&formats)); } #[test] fn test_content_hash() { let mut detector = LoopDetector::new(); let data = b"Hello, World!"; detector.record_content(data, ClipboardSource::Remote); assert!(detector.would_cause_content_loop(data, ClipboardSource::Local)); assert!(!detector.would_cause_content_loop(b"Different", ClipboardSource::Local)); } #[test] fn test_clear_history() { let mut detector = LoopDetector::new(); let formats = vec![ClipboardFormat::unicode_text()]; detector.record_formats(&formats, ClipboardSource::Remote); detector.clear(); assert!(!detector.would_cause_loop(&formats)); } #[test] fn test_compute_hash() { let hash1 = LoopDetector::compute_hash(b"test"); let hash2 = LoopDetector::compute_hash(b"test"); let hash3 = LoopDetector::compute_hash(b"different"); assert_eq!(hash1, hash2); assert_ne!(hash1, hash3); } #[test] fn test_rate_limit_disabled_by_default() { let detector = LoopDetector::new(); // Without rate limiting, should never be rate limited assert!(!detector.is_rate_limited(ClipboardSource::Remote)); assert!(!detector.is_rate_limited(ClipboardSource::Local)); } #[test] fn test_rate_limit_config() { let config = LoopDetectionConfig::with_rate_limit(200); assert_eq!(config.rate_limit_ms, Some(200)); let mut detector = LoopDetector::with_config(config); // First check - not rate limited assert!(!detector.is_rate_limited(ClipboardSource::Remote)); // Record sync detector.record_sync(ClipboardSource::Remote); // Immediately after - should be rate limited assert!(detector.is_rate_limited(ClipboardSource::Remote)); // But Local should not be affected assert!(!detector.is_rate_limited(ClipboardSource::Local)); } #[test] fn test_rate_limit_clear() { let config = LoopDetectionConfig::with_rate_limit(200); let mut detector = LoopDetector::with_config(config); detector.record_sync(ClipboardSource::Remote); assert!(detector.is_rate_limited(ClipboardSource::Remote)); detector.clear(); assert!(!detector.is_rate_limited(ClipboardSource::Remote)); } #[test] fn test_should_skip_sync_combined() { let config = LoopDetectionConfig::with_rate_limit(200); let mut detector = LoopDetector::with_config(config); let formats = vec![ClipboardFormat::unicode_text()]; // Initially: not rate limited, no loop assert!(!detector.should_skip_sync(&formats, ClipboardSource::Remote)); // Record from RDP detector.record_formats(&formats, ClipboardSource::Remote); detector.record_sync(ClipboardSource::Remote); // Now should skip for Local (loop detection) assert!(detector.should_skip_sync(&formats, ClipboardSource::Local)); // And skip for RDP (rate limiting) assert!(detector.should_skip_sync(&formats, ClipboardSource::Remote)); } } lamco-clipboard-core-0.6.0/src/sanitize.rs000064400000000000000000000427331046102023000166010ustar 00000000000000//! Cross-platform sanitization utilities for clipboard data. //! //! This module provides functions to safely convert clipboard data between //! Linux and Windows environments, handling: //! //! - Filename character restrictions //! - Reserved filenames //! - Text encoding and line endings //! - File URI parsing //! //! # Example //! //! ```rust //! use lamco_clipboard_core::sanitize::{sanitize_filename_for_windows, parse_file_uris}; //! //! // Sanitize a Linux filename for Windows //! let safe = sanitize_filename_for_windows("file:with:colons.txt"); //! assert_eq!(safe, "file_with_colons.txt"); //! //! // Parse file URIs from clipboard //! let uris = b"copy\nfile:///home/user/test.txt\n"; //! let paths = parse_file_uris(uris); //! ``` use std::path::PathBuf; // ============================================================================= // Windows Filename Sanitization // ============================================================================= /// Characters that are invalid in Windows filenames. const WINDOWS_INVALID_CHARS: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|']; /// Reserved filenames in Windows (case-insensitive). /// These cannot be used as filenames, even with extensions. const WINDOWS_RESERVED_NAMES: &[&str] = &[ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; /// Maximum filename length for Windows (without path). const WINDOWS_MAX_FILENAME_LEN: usize = 255; /// Sanitize a filename for use on Windows. /// /// This function: /// - Replaces invalid characters (`\ / : * ? " < > |`) with underscores /// - Handles reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) by prefixing with `_` /// - Removes trailing dots and spaces (Windows silently strips these) /// - Truncates to 255 characters if necessary /// - Handles empty filenames /// /// # Arguments /// /// * `filename` - The original filename (just the name, not a full path) /// /// # Returns /// /// A sanitized filename safe for use on Windows filesystems. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::sanitize::sanitize_filename_for_windows; /// /// assert_eq!(sanitize_filename_for_windows("normal.txt"), "normal.txt"); /// assert_eq!(sanitize_filename_for_windows("file:name.txt"), "file_name.txt"); /// assert_eq!(sanitize_filename_for_windows("CON.txt"), "_CON.txt"); /// assert_eq!(sanitize_filename_for_windows("file.txt."), "file.txt"); /// ``` pub fn sanitize_filename_for_windows(filename: &str) -> String { if filename.is_empty() { return "_unnamed_".to_string(); } // Replace invalid characters with underscores let mut sanitized: String = filename .chars() .map(|c| { if WINDOWS_INVALID_CHARS.contains(&c) || c.is_control() { '_' } else { c } }) .collect(); // Remove trailing dots and spaces (Windows strips these silently) while sanitized.ends_with('.') || sanitized.ends_with(' ') { sanitized.pop(); } // Handle empty result after stripping if sanitized.is_empty() { return "_unnamed_".to_string(); } // Check for reserved names (case-insensitive, with or without extension) let name_upper = sanitized.to_uppercase(); let base_name = name_upper.split('.').next().unwrap_or(""); if WINDOWS_RESERVED_NAMES.contains(&base_name) { sanitized = format!("_{}", sanitized); } // Truncate if too long (preserve extension if possible) if sanitized.len() > WINDOWS_MAX_FILENAME_LEN { if let Some(dot_pos) = sanitized.rfind('.') { let ext = &sanitized[dot_pos..]; let ext_len = ext.len(); if ext_len < WINDOWS_MAX_FILENAME_LEN - 1 { let base_max = WINDOWS_MAX_FILENAME_LEN - ext_len; let base = &sanitized[..dot_pos]; // Truncate base, keeping extension let truncated_base: String = base.chars().take(base_max).collect(); sanitized = format!("{}{}", truncated_base, ext); } else { // Extension itself is too long, just truncate sanitized = sanitized.chars().take(WINDOWS_MAX_FILENAME_LEN).collect(); } } else { sanitized = sanitized.chars().take(WINDOWS_MAX_FILENAME_LEN).collect(); } } sanitized } // ============================================================================= // Linux Filename Sanitization // ============================================================================= /// Characters that are invalid in Linux filenames. /// Only forward slash and null byte are truly invalid. const LINUX_INVALID_CHARS: &[char] = &['/', '\0']; /// Maximum filename length for most Linux filesystems. const LINUX_MAX_FILENAME_LEN: usize = 255; /// Sanitize a filename for use on Linux. /// /// Linux is much more permissive than Windows, but we still handle: /// - Forward slash (only truly invalid character besides null) /// - Null bytes /// - Backslashes (convert to underscores for safety with shell commands) /// - Leading dashes (can be confused with command options) /// - Truncation to 255 characters /// /// # Arguments /// /// * `filename` - The original filename (just the name, not a full path) /// /// # Returns /// /// A sanitized filename safe for use on Linux filesystems. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::sanitize::sanitize_filename_for_linux; /// /// assert_eq!(sanitize_filename_for_linux("normal.txt"), "normal.txt"); /// assert_eq!(sanitize_filename_for_linux("path/file.txt"), "path_file.txt"); /// assert_eq!(sanitize_filename_for_linux("-dangerous"), "_-dangerous"); /// ``` pub fn sanitize_filename_for_linux(filename: &str) -> String { if filename.is_empty() { return "_unnamed_".to_string(); } // Replace invalid characters let mut sanitized: String = filename .chars() .map(|c| { if LINUX_INVALID_CHARS.contains(&c) || c == '\\' { '_' } else { c } }) .collect(); // Handle leading dash (can be confused with command options) if sanitized.starts_with('-') { sanitized = format!("_{}", sanitized); } // Handle empty result if sanitized.is_empty() || sanitized == "." || sanitized == ".." { return "_unnamed_".to_string(); } // Truncate if too long if sanitized.len() > LINUX_MAX_FILENAME_LEN { if let Some(dot_pos) = sanitized.rfind('.') { let ext = &sanitized[dot_pos..]; let ext_len = ext.len(); if ext_len < LINUX_MAX_FILENAME_LEN - 1 { let base_max = LINUX_MAX_FILENAME_LEN - ext_len; let base = &sanitized[..dot_pos]; let truncated_base: String = base.chars().take(base_max).collect(); sanitized = format!("{}{}", truncated_base, ext); } else { sanitized = sanitized.chars().take(LINUX_MAX_FILENAME_LEN).collect(); } } else { sanitized = sanitized.chars().take(LINUX_MAX_FILENAME_LEN).collect(); } } sanitized } // ============================================================================= // File URI Parsing // ============================================================================= /// Parse file URIs from clipboard data. /// /// Handles both standard `text/uri-list` format and GNOME's /// `x-special/gnome-copied-files` format. /// /// # Format Examples /// /// **text/uri-list:** /// ```text /// file:///home/user/document.pdf /// file:///home/user/image.png /// ``` /// /// **x-special/gnome-copied-files:** /// ```text /// copy /// file:///home/user/document.pdf /// ``` /// /// # Arguments /// /// * `data` - Raw clipboard data bytes /// /// # Returns /// /// A vector of parsed file paths. Invalid URIs or non-existent files are skipped. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::sanitize::parse_file_uris; /// /// let data = b"file:///tmp/test.txt\nfile:///tmp/other.txt\n"; /// let paths = parse_file_uris(data); /// // Returns paths that exist on the filesystem /// ``` pub fn parse_file_uris(data: &[u8]) -> Vec { let text = String::from_utf8_lossy(data); let mut paths = Vec::new(); for line in text.lines() { let line = line.trim(); // Skip empty lines and gnome-copied-files prefixes if line.is_empty() || line == "copy" || line == "cut" { continue; } // Parse file:// URI if let Some(path) = parse_file_uri(line) { if path.exists() { paths.push(path); } else { tracing::warn!("File URI points to non-existent path: {:?}", path); } } } paths } /// Parse a single file:// URI to a PathBuf. /// /// Handles URL-encoded characters (e.g., `%20` for space). /// /// # Arguments /// /// * `uri` - A file:// URI string /// /// # Returns /// /// The decoded path, or None if the URI is invalid. pub fn parse_file_uri(uri: &str) -> Option { let path_str = uri.strip_prefix("file://")?; // URL decode the path let decoded = percent_decode(path_str); Some(PathBuf::from(decoded)) } /// Simple percent-decoding for file URIs. /// /// Decodes URL-encoded characters like `%20` (space), `%2F` (slash), etc. fn percent_decode(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); while let Some(c) = chars.next() { if c == '%' { // Try to read two hex digits let hex: String = chars.by_ref().take(2).collect(); if hex.len() == 2 { if let Ok(byte) = u8::from_str_radix(&hex, 16) { // For multi-byte UTF-8, we need to collect bytes // Simple case: ASCII character if byte < 128 { result.push(byte as char); continue; } // For non-ASCII, decode as UTF-8 byte sequence let mut bytes = vec![byte]; while chars.peek() == Some(&'%') { chars.next(); // consume '%' let hex2: String = chars.by_ref().take(2).collect(); if hex2.len() == 2 { if let Ok(b) = u8::from_str_radix(&hex2, 16) { bytes.push(b); // Check if we have a complete UTF-8 sequence if let Ok(s) = std::str::from_utf8(&bytes) { result.push_str(s); bytes.clear(); break; } } else { // Invalid hex, put back what we consumed result.push('%'); result.push_str(&hex2); break; } } } if !bytes.is_empty() { // Incomplete UTF-8 sequence, use replacement char result.push('\u{FFFD}'); } continue; } } // Invalid percent encoding, keep literal result.push('%'); result.push_str(&hex); } else { result.push(c); } } result } // ============================================================================= // Text Sanitization // ============================================================================= /// Convert text line endings from Unix (LF) to Windows (CRLF). /// /// This ensures text pasted in Windows applications displays correctly. /// Already-present CRLF sequences are preserved (not doubled). /// /// # Arguments /// /// * `text` - The input text with Unix line endings /// /// # Returns /// /// Text with Windows-style line endings. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::sanitize::convert_line_endings_to_windows; /// /// let unix = "line1\nline2\nline3"; /// let windows = convert_line_endings_to_windows(unix); /// assert_eq!(windows, "line1\r\nline2\r\nline3"); /// ``` pub fn convert_line_endings_to_windows(text: &str) -> String { // First normalize any existing CRLF to LF, then convert all LF to CRLF let normalized = text.replace("\r\n", "\n"); normalized.replace('\n', "\r\n") } /// Convert text line endings from Windows (CRLF) to Unix (LF). /// /// This ensures text pasted in Linux applications displays correctly. /// /// # Arguments /// /// * `text` - The input text with Windows line endings /// /// # Returns /// /// Text with Unix-style line endings. /// /// # Example /// /// ```rust /// use lamco_clipboard_core::sanitize::convert_line_endings_to_unix; /// /// let windows = "line1\r\nline2\r\nline3"; /// let unix = convert_line_endings_to_unix(windows); /// assert_eq!(unix, "line1\nline2\nline3"); /// ``` pub fn convert_line_endings_to_unix(text: &str) -> String { text.replace("\r\n", "\n") } /// Sanitize text for Windows clipboard. /// /// This function: /// - Converts line endings to CRLF /// - Ensures valid UTF-8 (replaces invalid sequences) /// - Removes null bytes /// /// # Arguments /// /// * `text` - The input text /// /// # Returns /// /// Sanitized text safe for Windows clipboard. pub fn sanitize_text_for_windows(text: &str) -> String { let mut result = convert_line_endings_to_windows(text); // Remove any null bytes result.retain(|c| c != '\0'); result } /// Sanitize text for Linux clipboard. /// /// This function: /// - Converts line endings to LF /// - Ensures valid UTF-8 (replaces invalid sequences) /// - Removes null bytes /// /// # Arguments /// /// * `text` - The input text /// /// # Returns /// /// Sanitized text safe for Linux clipboard. pub fn sanitize_text_for_linux(text: &str) -> String { let mut result = convert_line_endings_to_unix(text); // Remove any null bytes result.retain(|c| c != '\0'); result } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; #[test] fn test_sanitize_filename_for_windows_basic() { assert_eq!(sanitize_filename_for_windows("normal.txt"), "normal.txt"); assert_eq!(sanitize_filename_for_windows("file name.txt"), "file name.txt"); } #[test] fn test_sanitize_filename_for_windows_invalid_chars() { assert_eq!(sanitize_filename_for_windows("file:name.txt"), "file_name.txt"); assert_eq!( sanitize_filename_for_windows("a\\b/c:d*e?f\"gi|j.txt"), "a_b_c_d_e_f_g_h_i_j.txt" ); } #[test] fn test_sanitize_filename_for_windows_reserved_names() { assert_eq!(sanitize_filename_for_windows("CON"), "_CON"); assert_eq!(sanitize_filename_for_windows("con.txt"), "_con.txt"); assert_eq!(sanitize_filename_for_windows("COM1.log"), "_COM1.log"); assert_eq!(sanitize_filename_for_windows("NUL"), "_NUL"); } #[test] fn test_sanitize_filename_for_windows_trailing() { assert_eq!(sanitize_filename_for_windows("file.txt."), "file.txt"); assert_eq!(sanitize_filename_for_windows("file.txt..."), "file.txt"); assert_eq!(sanitize_filename_for_windows("file "), "file"); assert_eq!(sanitize_filename_for_windows("..."), "_unnamed_"); } #[test] fn test_sanitize_filename_for_windows_empty() { assert_eq!(sanitize_filename_for_windows(""), "_unnamed_"); } #[test] fn test_sanitize_filename_for_linux_basic() { assert_eq!(sanitize_filename_for_linux("normal.txt"), "normal.txt"); assert_eq!(sanitize_filename_for_linux("file:name.txt"), "file:name.txt"); // Colons are OK on Linux } #[test] fn test_sanitize_filename_for_linux_slash() { assert_eq!(sanitize_filename_for_linux("path/file.txt"), "path_file.txt"); assert_eq!(sanitize_filename_for_linux("back\\slash.txt"), "back_slash.txt"); } #[test] fn test_sanitize_filename_for_linux_leading_dash() { assert_eq!(sanitize_filename_for_linux("-rf"), "_-rf"); assert_eq!(sanitize_filename_for_linux("--help"), "_--help"); } #[test] fn test_parse_file_uri() { assert_eq!( parse_file_uri("file:///home/user/test.txt"), Some(PathBuf::from("/home/user/test.txt")) ); assert_eq!( parse_file_uri("file:///home/user/my%20file.txt"), Some(PathBuf::from("/home/user/my file.txt")) ); assert_eq!(parse_file_uri("not-a-uri"), None); assert_eq!(parse_file_uri("http://example.com"), None); } #[test] fn test_percent_decode() { assert_eq!(percent_decode("hello%20world"), "hello world"); assert_eq!(percent_decode("file%2Fname"), "file/name"); assert_eq!(percent_decode("no-encoding"), "no-encoding"); assert_eq!(percent_decode("%"), "%"); // Incomplete } #[test] fn test_convert_line_endings() { assert_eq!(convert_line_endings_to_windows("a\nb\nc"), "a\r\nb\r\nc"); assert_eq!(convert_line_endings_to_windows("a\r\nb\nc"), "a\r\nb\r\nc"); assert_eq!(convert_line_endings_to_unix("a\r\nb\r\nc"), "a\nb\nc"); } } lamco-clipboard-core-0.6.0/src/transfer.rs000064400000000000000000000324131046102023000165710ustar 00000000000000//! Chunked transfer engine for large clipboard data. //! //! Handles transferring large clipboard content (files, images) in chunks //! with progress tracking and integrity verification. use sha2::{Digest, Sha256}; use std::time::{Duration, Instant}; use crate::{ClipboardError, ClipboardResult}; /// Default chunk size: 64KB pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024; /// Default maximum data size: 16MB pub const DEFAULT_MAX_SIZE: usize = 16 * 1024 * 1024; /// Default timeout: 30 seconds pub const DEFAULT_TIMEOUT_MS: u64 = 30_000; /// State of a transfer operation #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransferState { /// Transfer not started Pending, /// Transfer in progress InProgress, /// Transfer completed successfully Completed, /// Transfer was cancelled Cancelled, /// Transfer failed Failed, } impl TransferState { /// Returns true if the transfer is still active pub fn is_active(&self) -> bool { matches!(self, Self::Pending | Self::InProgress) } /// Returns true if the transfer has finished (success or failure) pub fn is_finished(&self) -> bool { matches!(self, Self::Completed | Self::Cancelled | Self::Failed) } } /// Progress information for a transfer #[derive(Debug, Clone)] pub struct TransferProgress { /// Total bytes to transfer pub total_bytes: u64, /// Bytes transferred so far pub transferred_bytes: u64, /// Current transfer state pub state: TransferState, /// Transfer start time pub started_at: Option, /// Estimated time remaining in milliseconds pub eta_ms: Option, } impl TransferProgress { /// Create new progress tracker pub fn new(total_bytes: u64) -> Self { Self { total_bytes, transferred_bytes: 0, state: TransferState::Pending, started_at: None, eta_ms: None, } } /// Get completion percentage (0.0 - 100.0) pub fn percentage(&self) -> f64 { if self.total_bytes == 0 { return 100.0; } (self.transferred_bytes as f64 / self.total_bytes as f64) * 100.0 } /// Get bytes per second transfer rate pub fn bytes_per_second(&self) -> Option { let started = self.started_at?; let elapsed = started.elapsed().as_secs_f64(); if elapsed > 0.0 { Some(self.transferred_bytes as f64 / elapsed) } else { None } } } /// Configuration for the transfer engine #[derive(Debug, Clone)] pub struct TransferConfig { /// Chunk size in bytes pub chunk_size: usize, /// Maximum total size in bytes pub max_size: usize, /// Timeout in milliseconds pub timeout_ms: u64, /// Whether to verify integrity with hash pub verify_integrity: bool, } impl Default for TransferConfig { fn default() -> Self { Self { chunk_size: DEFAULT_CHUNK_SIZE, max_size: DEFAULT_MAX_SIZE, timeout_ms: DEFAULT_TIMEOUT_MS, verify_integrity: true, } } } /// Handles chunked transfers of clipboard data. /// /// # Features /// /// - Chunked transfer for large data /// - Progress tracking with ETA /// - Integrity verification via SHA256 /// - Timeout handling /// - Cancellation support /// /// # Example /// /// ```rust /// use lamco_clipboard_core::TransferEngine; /// /// let mut engine = TransferEngine::new(); /// /// // Prepare data for sending /// let data = vec![0u8; 1024 * 1024]; // 1MB /// let chunks = engine.prepare_send(&data).unwrap(); /// /// // Send chunks (to RDP, network, etc.) /// for (index, chunk) in chunks.iter().enumerate() { /// println!("Sending chunk {} of {} ({} bytes)", /// index + 1, chunks.len(), chunk.len()); /// } /// /// // Get the hash for verification /// let hash = engine.compute_hash(&data); /// ``` #[derive(Debug)] pub struct TransferEngine { /// Configuration config: TransferConfig, /// Current progress (for active transfer) progress: Option, /// Received chunks (for incoming transfer) received_chunks: Vec>, /// Expected hash (for verification) expected_hash: Option, /// Transfer start time started_at: Option, } impl Default for TransferEngine { fn default() -> Self { Self::new() } } impl TransferEngine { /// Create a new transfer engine with default configuration pub fn new() -> Self { Self::with_config(TransferConfig::default()) } /// Create a new transfer engine with custom configuration pub fn with_config(config: TransferConfig) -> Self { Self { config, progress: None, received_chunks: Vec::new(), expected_hash: None, started_at: None, } } /// Get current progress pub fn progress(&self) -> Option<&TransferProgress> { self.progress.as_ref() } /// Prepare data for chunked sending /// /// Returns a vector of chunks ready to be sent. pub fn prepare_send(&mut self, data: &[u8]) -> ClipboardResult>> { if data.len() > self.config.max_size { return Err(ClipboardError::DataSizeExceeded { actual: data.len(), max: self.config.max_size, }); } self.started_at = Some(Instant::now()); self.progress = Some(TransferProgress::new(data.len() as u64)); if let Some(ref mut progress) = self.progress { progress.state = TransferState::InProgress; progress.started_at = Some(Instant::now()); } let chunks: Vec> = data.chunks(self.config.chunk_size).map(|c| c.to_vec()).collect(); Ok(chunks) } /// Start receiving a chunked transfer pub fn start_receive(&mut self, total_size: u64, expected_hash: Option) -> ClipboardResult<()> { if total_size as usize > self.config.max_size { return Err(ClipboardError::DataSizeExceeded { actual: total_size as usize, max: self.config.max_size, }); } self.received_chunks.clear(); self.expected_hash = expected_hash; self.started_at = Some(Instant::now()); self.progress = Some(TransferProgress::new(total_size)); if let Some(ref mut progress) = self.progress { progress.state = TransferState::InProgress; progress.started_at = Some(Instant::now()); } Ok(()) } /// Receive a chunk of data pub fn receive_chunk(&mut self, chunk: Vec) -> ClipboardResult<()> { // Check timeout if let Some(started) = self.started_at { if started.elapsed() > Duration::from_millis(self.config.timeout_ms) { if let Some(ref mut progress) = self.progress { progress.state = TransferState::Failed; } return Err(ClipboardError::TransferTimeout(self.config.timeout_ms)); } } // Check if we have an active transfer let progress = self .progress .as_mut() .ok_or_else(|| ClipboardError::InvalidState("no active transfer".to_string()))?; // Check if transfer is still active if !progress.state.is_active() { return Err(ClipboardError::InvalidState("transfer not active".to_string())); } // Update progress progress.transferred_bytes += chunk.len() as u64; // Calculate ETA if let Some(started) = progress.started_at { let elapsed = started.elapsed().as_secs_f64(); if elapsed > 0.0 && progress.transferred_bytes > 0 { let rate = progress.transferred_bytes as f64 / elapsed; let remaining = progress.total_bytes - progress.transferred_bytes; progress.eta_ms = Some((remaining as f64 / rate * 1000.0) as u64); } } // Store chunk self.received_chunks.push(chunk); // Check if complete if progress.transferred_bytes >= progress.total_bytes { progress.state = TransferState::Completed; } Ok(()) } /// Finalize the receive and get the assembled data pub fn finalize_receive(&mut self) -> ClipboardResult> { let progress = self .progress .as_ref() .ok_or_else(|| ClipboardError::InvalidState("no active transfer".to_string()))?; if progress.state != TransferState::Completed { return Err(ClipboardError::InvalidState(format!( "transfer not completed: {:?}", progress.state ))); } // Assemble data let mut data = Vec::with_capacity(progress.total_bytes as usize); for chunk in &self.received_chunks { data.extend_from_slice(chunk); } // Verify integrity if hash was provided if self.config.verify_integrity { if let Some(ref expected) = self.expected_hash { let actual = self.compute_hash(&data); if actual != *expected { return Err(ClipboardError::FormatConversion( "integrity check failed: hash mismatch".to_string(), )); } } } // Clear state self.received_chunks.clear(); self.progress = None; self.expected_hash = None; self.started_at = None; Ok(data) } /// Cancel the current transfer pub fn cancel(&mut self) { if let Some(ref mut progress) = self.progress { progress.state = TransferState::Cancelled; } self.received_chunks.clear(); } /// Compute SHA256 hash of data pub fn compute_hash(&self, data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); format!("{:x}", hasher.finalize()) } /// Check if a transfer is in progress pub fn is_active(&self) -> bool { self.progress.as_ref().map(|p| p.state.is_active()).unwrap_or(false) } /// Get the configured chunk size pub fn chunk_size(&self) -> usize { self.config.chunk_size } /// Get the configured maximum size pub fn max_size(&self) -> usize { self.config.max_size } } #[cfg(test)] mod tests { use super::*; #[test] fn test_prepare_send() { let mut engine = TransferEngine::new(); let data = vec![0u8; 100_000]; // 100KB let chunks = engine.prepare_send(&data).unwrap(); // Should be ~2 chunks at 64KB each assert_eq!(chunks.len(), 2); assert_eq!(chunks[0].len(), 64 * 1024); assert_eq!(chunks[1].len(), 100_000 - 64 * 1024); } #[test] fn test_receive_transfer() { let mut engine = TransferEngine::new(); // Start receiving 1000 bytes engine.start_receive(1000, None).unwrap(); // Receive in chunks engine.receive_chunk(vec![0u8; 500]).unwrap(); engine.receive_chunk(vec![0u8; 500]).unwrap(); // Check progress let progress = engine.progress().unwrap(); assert_eq!(progress.state, TransferState::Completed); assert_eq!(progress.transferred_bytes, 1000); // Finalize let data = engine.finalize_receive().unwrap(); assert_eq!(data.len(), 1000); } #[test] fn test_integrity_verification() { let mut engine = TransferEngine::new(); let original_data = b"Hello, World!".to_vec(); let hash = engine.compute_hash(&original_data); // Start receive with hash engine .start_receive(original_data.len() as u64, Some(hash.clone())) .unwrap(); engine.receive_chunk(original_data.clone()).unwrap(); // Should succeed with correct hash let data = engine.finalize_receive().unwrap(); assert_eq!(data, original_data); } #[test] fn test_integrity_failure() { let mut engine = TransferEngine::new(); let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000".to_string(); engine.start_receive(13, Some(wrong_hash)).unwrap(); engine.receive_chunk(b"Hello, World!".to_vec()).unwrap(); // Should fail with wrong hash let result = engine.finalize_receive(); assert!(result.is_err()); } #[test] fn test_cancel() { let mut engine = TransferEngine::new(); engine.start_receive(1000, None).unwrap(); engine.receive_chunk(vec![0u8; 500]).unwrap(); engine.cancel(); let progress = engine.progress().unwrap(); assert_eq!(progress.state, TransferState::Cancelled); } #[test] fn test_progress_percentage() { let mut progress = TransferProgress::new(100); progress.transferred_bytes = 50; assert!((progress.percentage() - 50.0).abs() < 0.01); } #[test] fn test_data_size_exceeded() { let config = TransferConfig { max_size: 100, ..Default::default() }; let mut engine = TransferEngine::with_config(config); let result = engine.prepare_send(&vec![0u8; 200]); assert!(matches!(result, Err(ClipboardError::DataSizeExceeded { .. }))); } }