Skip to main content

day_spec/
transfer.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Native drag data and synchronous destination policy. No clipboard or process-local transport.
5use crate::Point;
6use std::{rc::Rc, sync::Arc};
7
8pub const BUNDLE_MIME: &str = "application/vnd.day.transfer";
9pub const MAX_BYTES: usize = 64 * 1024 * 1024;
10pub const MAX_ITEMS: usize = 256;
11
12/// One representation of an item. File references use standard `text/uri-list` bytes.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Representation {
15    pub mime: String,
16    pub bytes: Arc<Vec<u8>>,
17}
18impl Representation {
19    pub fn new(mime: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
20        Self {
21            mime: mime.into(),
22            bytes: Arc::new(bytes.into()),
23        }
24    }
25}
26/// Representations in an item are alternatives; items in an offer are separate objects.
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct Item {
29    pub representations: Vec<Representation>,
30}
31impl Item {
32    pub fn new(representations: Vec<Representation>) -> Self {
33        Self { representations }
34    }
35    pub fn get(&self, mime: &str) -> Option<&[u8]> {
36        self.representations
37            .iter()
38            .find(|r| r.mime == mime)
39            .map(|r| r.bytes.as_slice())
40    }
41}
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum Operation {
44    #[default]
45    None,
46    Copy,
47    Move,
48    Link,
49}
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
51pub struct Offer {
52    pub items: Vec<Item>,
53}
54
55#[derive(Clone, Debug)]
56pub struct Location {
57    /// Target-local logical coordinates, not screen pixels.
58    pub position: Point,
59    pub types: Vec<String>,
60    pub allowed: Vec<Operation>,
61    /// Provenance supplied by the native session, never decoded from a payload.
62    pub local: bool,
63}
64impl Location {
65    pub fn has(&self, mime: &str) -> bool {
66        self.types.iter().any(|m| m == mime)
67    }
68}
69#[derive(Clone, Debug)]
70pub struct Drop {
71    pub local: bool,
72    pub position: Point,
73    pub operation: Operation,
74    pub items: Vec<Item>,
75}
76/// Called on the UI thread at native drag start, outside any tree borrow.
77pub type Source = Rc<dyn Fn(Point) -> Option<Offer>>;
78/// Policy is consulted during hover AND again at drop. None rejects this location.
79/// `receive` returns whether the owned data was accepted. Do not delete a source based on
80/// asynchronous work started here: external transfers currently advertise Copy only.
81#[derive(Clone)]
82pub struct Target {
83    pub types: Vec<String>,
84    pub accept: Rc<dyn Fn(&Location) -> Operation>,
85    pub receive: Rc<dyn Fn(Drop) -> bool>,
86}
87impl Target {
88    pub fn proposal(&self, location: &Location) -> Operation {
89        let op = crate::ffi_guard::contain(Operation::None, || (self.accept)(location));
90        if location.allowed.contains(&op) {
91            op
92        } else {
93            Operation::None
94        }
95    }
96    pub fn deliver(&self, location: Location, offer: Offer) -> bool {
97        let op = self.proposal(&location);
98        op != Operation::None
99            && crate::ffi_guard::contain(false, || {
100                (self.receive)(Drop {
101                    local: location.local,
102                    position: location.position,
103                    operation: op,
104                    items: offer.items,
105                })
106            })
107    }
108}
109
110/// Bounded, versioned transport for multiple items through native MIME providers. Standard
111/// image and file formats are published alongside this, for applications that do not know Day.
112impl Offer {
113    pub fn types(&self) -> Vec<String> {
114        let mut out = Vec::new();
115        for r in self.items.iter().flat_map(|i| &i.representations) {
116            if !out.contains(&r.mime) {
117                out.push(r.mime.clone());
118            }
119        }
120        out
121    }
122    pub fn encode(&self) -> Option<Vec<u8>> {
123        if self.items.is_empty() || self.items.len() > MAX_ITEMS {
124            return None;
125        }
126        let mut out = b"DAYDND\0\x01".to_vec();
127        out.extend_from_slice(&(self.items.len() as u32).to_le_bytes());
128        for item in &self.items {
129            if item.representations.is_empty() || item.representations.len() > 32 {
130                return None;
131            }
132            out.extend_from_slice(&(item.representations.len() as u32).to_le_bytes());
133            let mut seen = std::collections::HashSet::new();
134            for r in &item.representations {
135                if !valid_mime(&r.mime) || r.mime == BUNDLE_MIME || !seen.insert(&r.mime) {
136                    return None;
137                }
138                if out
139                    .len()
140                    .checked_add(r.mime.len() + 8)?
141                    .checked_add(r.bytes.len())?
142                    > MAX_BYTES
143                {
144                    return None;
145                }
146                out.extend_from_slice(&(r.mime.len() as u32).to_le_bytes());
147                out.extend_from_slice(&(r.bytes.len() as u32).to_le_bytes());
148                out.extend_from_slice(r.mime.as_bytes());
149                out.extend_from_slice(&r.bytes);
150            }
151        }
152        Some(out)
153    }
154    pub fn decode(mut bytes: &[u8]) -> Option<Self> {
155        fn take<'a>(b: &mut &'a [u8], n: usize) -> Option<&'a [u8]> {
156            let (a, rest) = b.split_at_checked(n)?;
157            *b = rest;
158            Some(a)
159        }
160        fn number(b: &mut &[u8]) -> Option<usize> {
161            Some(u32::from_le_bytes(take(b, 4)?.try_into().ok()?) as usize)
162        }
163        if bytes.len() > MAX_BYTES || take(&mut bytes, 8)? != b"DAYDND\0\x01" {
164            return None;
165        }
166        let count = number(&mut bytes)?;
167        if count == 0 || count > MAX_ITEMS {
168            return None;
169        }
170        let mut items = Vec::new();
171        for _ in 0..count {
172            let count = number(&mut bytes)?;
173            if count == 0 || count > 32 {
174                return None;
175            }
176            let mut representations = Vec::new();
177            for _ in 0..count {
178                let m = number(&mut bytes)?;
179                let n = number(&mut bytes)?;
180                if m > 255 {
181                    return None;
182                }
183                let mime = std::str::from_utf8(take(&mut bytes, m)?).ok()?;
184                if !valid_mime(mime)
185                    || mime == BUNDLE_MIME
186                    || representations
187                        .iter()
188                        .any(|r: &Representation| r.mime == mime)
189                {
190                    return None;
191                }
192                representations.push(Representation::new(mime, take(&mut bytes, n)?));
193            }
194            items.push(Item { representations });
195        }
196        if !bytes.is_empty() {
197            return None;
198        }
199        Some(Self { items })
200    }
201}
202pub fn valid_mime(m: &str) -> bool {
203    m.len() <= 255
204        && m.split_once('/')
205            .is_some_and(|(a, b)| !a.is_empty() && !b.is_empty() && !b.contains('/'))
206        && m.bytes()
207            .all(|c| c.is_ascii_alphanumeric() || b"/!#$&^_.+-".contains(&c))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    #[test]
214    fn items_and_alternatives_survive_untrusted_transport() {
215        let offer = Offer {
216            items: vec![
217                Item::new(vec![
218                    Representation::new("image/png", [0, 255]),
219                    Representation::new("application/x-test", [9, 0]),
220                ]),
221                Item::new(vec![Representation::new(
222                    "text/uri-list",
223                    b"file:///tmp/a%20b\r\n",
224                )]),
225            ],
226        };
227        let encoded = offer.encode().unwrap();
228        assert_eq!(Offer::decode(&encoded), Some(offer));
229        for n in 0..encoded.len() {
230            assert!(Offer::decode(&encoded[..n]).is_none());
231        }
232        let mut extra = encoded;
233        extra.push(0);
234        assert!(Offer::decode(&extra).is_none());
235    }
236    #[test]
237    fn location_guard_is_rechecked_and_cannot_escalate_operations() {
238        let target = Target {
239            types: vec![],
240            accept: Rc::new(|p| {
241                if p.position.x < 50.0 {
242                    Operation::Copy
243                } else {
244                    Operation::Move
245                }
246            }),
247            receive: Rc::new(|_| true),
248        };
249        let mut p = Location {
250            position: Point::new(5., 5.),
251            types: vec![],
252            allowed: vec![Operation::Copy],
253            local: true,
254        };
255        assert_eq!(target.proposal(&p), Operation::Copy);
256        p.position.x = 80.;
257        assert!(!target.deliver(p, Offer::default()));
258    }
259}
260
261/// Parse native URI-list records without treating remote or opaque URIs as filesystem paths.
262/// Callers still need the platform's access lease for sandboxed/provider-backed references.
263pub fn file_paths(bytes: &[u8]) -> Option<Vec<std::path::PathBuf>> {
264    let text = std::str::from_utf8(bytes).ok()?;
265    let mut paths = Vec::new();
266    for line in text
267        .lines()
268        .map(str::trim)
269        .filter(|l| !l.is_empty() && !l.starts_with('#'))
270    {
271        let uri = line.strip_prefix("file://")?;
272        let path = if uri.starts_with('/') {
273            uri
274        } else {
275            uri.strip_prefix("localhost/")
276                .map(|p| &uri[uri.len() - p.len() - 1..])?
277        };
278        if path.contains(['?', '#']) {
279            return None;
280        }
281        let mut decoded = Vec::with_capacity(path.len());
282        let mut raw = path.bytes();
283        while let Some(b) = raw.next() {
284            let b = if b == b'%' {
285                let hi = (raw.next()? as char).to_digit(16)?;
286                let lo = (raw.next()? as char).to_digit(16)?;
287                (hi * 16 + lo) as u8
288            } else {
289                b
290            };
291            if b == 0 {
292                return None;
293            }
294            decoded.push(b);
295        }
296        #[cfg(unix)]
297        let path = {
298            use std::os::unix::ffi::OsStringExt;
299            std::path::PathBuf::from(std::ffi::OsString::from_vec(decoded))
300        };
301        #[cfg(not(unix))]
302        let path = {
303            let s = String::from_utf8(decoded).ok()?;
304            #[cfg(windows)]
305            let s = if s.as_bytes().get(2) == Some(&b':') {
306                s[1..].to_string()
307            } else {
308                s
309            };
310            std::path::PathBuf::from(s)
311        };
312        paths.push(path);
313        if paths.len() > MAX_ITEMS {
314            return None;
315        }
316    }
317    (!paths.is_empty()).then_some(paths)
318}
319
320#[cfg(test)]
321mod file_tests {
322    use super::*;
323    #[test]
324    fn uri_references_decode_without_fetching_remote_resources() {
325        assert_eq!(
326            file_paths(b"# files\r\nfile:///tmp/a%20b%23c.png\r\nfile://localhost/tmp/two\r\n")
327                .unwrap(),
328            vec![
329                std::path::PathBuf::from("/tmp/a b#c.png"),
330                std::path::PathBuf::from("/tmp/two")
331            ]
332        );
333        for s in [
334            "https://example.com/a",
335            "file://remote/a",
336            "file:///tmp/%00",
337            "file:///tmp/%xx",
338            "file:///tmp/a#fragment",
339        ] {
340            assert!(file_paths(s.as_bytes()).is_none());
341        }
342    }
343}