Skip to main content

netget/server/rsync/
wire.rs

1//! rsync's daemon protocol, pinned at **protocol 29**: the `@RSYNCD:` text handshake, the
2//! argument list, multiplexed framing, the file list, and the MD4 checksums. Shared by the
3//! daemon (`src/server/rsync/`) and the client (`src/client/rsync/`).
4//!
5//! Why 29: it is the smallest version rsync 3.2.7 still speaks in full. There are no
6//! compat flags, no checksum or compression negotiation, no incremental recursion, plain int32
7//! file indexes, fixed-width file-list fields, and a client→daemon direction that is never
8//! multiplexed. Everything is MD4: the whole-file sum is MD4(le32(seed) ‖ data). Advertise
9//! exactly `29.0`: a non-zero sub-version makes a 3.2.7 peer drop to 28.
10use anyhow::{bail, ensure, Context, Result};
11use md4::{Digest, Md4};
12use serde_json::{json, Value};
13use tokio::io::{AsyncRead, AsyncReadExt};
14
15pub const PROTOCOL: i32 = 29;
16pub const GREETING: &str = "@RSYNCD: 29.0\n";
17pub const NDX_DONE: i32 = -1;
18const MPLEX_BASE: u8 = 7;
19pub const MSG_DATA: u8 = 0;
20pub const MSG_ERROR_XFER: u8 = 1;
21pub const MSG_INFO: u8 = 2;
22pub const MSG_ERROR: u8 = 3;
23pub const MSG_WARNING: u8 = 4;
24pub const MSG_IO_ERROR: u8 = 22;
25pub const MSG_NOOP: u8 = 42;
26pub const MSG_ERROR_EXIT: u8 = 86;
27
28pub const ITEM_TRANSFER: u16 = 0x8000;
29pub const ITEM_IS_NEW: u16 = 0x2000;
30pub const ITEM_XNAME_FOLLOWS: u16 = 0x1000;
31pub const ITEM_BASIS_TYPE_FOLLOWS: u16 = 0x0800;
32
33const XMIT_TOP_DIR: u16 = 0x01;
34const XMIT_SAME_MODE: u16 = 0x02;
35const XMIT_EXTENDED_FLAGS: u16 = 0x04;
36const XMIT_SAME_UID: u16 = 0x08;
37const XMIT_SAME_GID: u16 = 0x10;
38const XMIT_SAME_NAME: u16 = 0x20;
39const XMIT_LONG_NAME: u16 = 0x40;
40const XMIT_SAME_TIME: u16 = 0x80;
41
42/// Literal data is sent in pieces of at most this many bytes (rsync's CHUNK_SIZE).
43pub const CHUNK: usize = 32 * 1024;
44/// A text line of the handshake, an argument, or a module line.
45pub const MAX_LINE: usize = 4096;
46pub const MAX_ARGS: usize = 64;
47/// A file-list name.
48pub const MAX_NAME: usize = 4096;
49/// Entries in one file list a daemon sends (what the client reads on a download).
50pub const MAX_ENTRIES: usize = 100_000;
51/// Entries in one uploading client's file list, devices and specials included: twice the files
52/// one upload may deliver, so the directories and symlinks around them fit.
53pub const MAX_UPLOAD_LIST_ENTRIES: usize = 2 * super::MAX_UPLOAD_FILES;
54/// Name and symlink-target bytes in one uploading client's file list, all entries together.
55/// Every name reaches the model in `rsync_upload_request`, so this also bounds that event.
56pub const MAX_UPLOAD_LIST_NAME_BYTES: usize = 1024 * 1024;
57/// Total bytes of filter rules a client may send.
58pub const MAX_FILTER_BYTES: usize = 64 * 1024;
59/// Block checksums one request may carry (count × (4 + s2length) bytes are read and dropped).
60pub const MAX_SUM_COUNT: i32 = 1 << 20;
61
62pub const S_IFMT: u32 = 0o170000;
63pub const S_IFREG: u32 = 0o100000;
64pub const S_IFDIR: u32 = 0o040000;
65pub const S_IFLNK: u32 = 0o120000;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Kind {
69    File,
70    Dir,
71    Symlink,
72}
73
74impl Kind {
75    pub fn name(self) -> &'static str {
76        match self {
77            Kind::File => "file",
78            Kind::Dir => "dir",
79            Kind::Symlink => "symlink",
80        }
81    }
82    pub fn of_mode(mode: u32) -> Option<Self> {
83        match mode & S_IFMT {
84            S_IFREG => Some(Kind::File),
85            S_IFDIR => Some(Kind::Dir),
86            S_IFLNK => Some(Kind::Symlink),
87            _ => None,
88        }
89    }
90}
91
92/// One file-list entry. `path` is relative to the transfer root, `/`-separated.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Entry {
95    pub path: String,
96    pub kind: Kind,
97    /// Regular files: the bytes. Symlinks: the target. Directories: empty.
98    pub data: Vec<u8>,
99    pub mode: u32,
100    pub mtime: u32,
101    pub uid: u32,
102    pub gid: u32,
103    /// Size as the list says it (a received list carries no data until it is fetched).
104    pub size: u64,
105}
106
107impl Entry {
108    pub fn is_dir(&self) -> bool {
109        self.kind == Kind::Dir
110    }
111}
112
113pub fn le32(x: i32) -> [u8; 4] {
114    x.to_le_bytes()
115}
116
117pub fn longint(x: u64) -> Vec<u8> {
118    if x <= 0x7fff_ffff {
119        (x as i32).to_le_bytes().to_vec()
120    } else {
121        let mut v = vec![0xff; 4];
122        v.extend_from_slice(&(x as i64).to_le_bytes());
123        v
124    }
125}
126
127/// MD4 over the pieces, in order.
128pub fn md4(parts: &[&[u8]]) -> [u8; 16] {
129    let mut h = Md4::new();
130    for p in parts {
131        h.update(p);
132    }
133    h.finalize().into()
134}
135
136/// The whole-file checksum at protocol 29: MD4(le32(seed) ‖ data), the seed always first.
137pub fn file_sum(seed: i32, data: &[u8]) -> [u8; 16] {
138    md4(&[&seed.to_le_bytes(), data])
139}
140
141/// The `-c` file-list checksum: MD4 of the contents, unseeded.
142pub fn list_sum(data: &[u8]) -> [u8; 16] {
143    md4(&[data])
144}
145
146/// The answer to `@RSYNCD: AUTHREQD <challenge>` at protocol 29 (no digest list was offered,
147/// so the daemon uses MD4 with a zero seed): base64 without padding of
148/// MD4(00 00 00 00 ‖ password ‖ challenge).
149pub fn auth_response(password: &str, challenge: &str) -> String {
150    use base64::Engine;
151    let digest = md4(&[&[0u8; 4], password.as_bytes(), challenge.as_bytes()]);
152    base64::engine::general_purpose::STANDARD_NO_PAD.encode(digest)
153}
154
155/// One multiplexed frame: a little-endian (tag << 24 | len) header, then the payload.
156pub fn frame(code: u8, payload: &[u8]) -> Vec<u8> {
157    let mut out = Vec::with_capacity(4 + payload.len());
158    let header = ((u32::from(MPLEX_BASE + code)) << 24) | payload.len() as u32;
159    out.extend_from_slice(&header.to_le_bytes());
160    out.extend_from_slice(payload);
161    out
162}
163
164/// MSG_DATA frames for a byte stream, split at 0xFFFF bytes.
165pub fn data_frames(bytes: &[u8]) -> Vec<u8> {
166    let mut out = Vec::with_capacity(bytes.len() + 4 * (bytes.len() / 0xffff + 1));
167    for chunk in bytes.chunks(0xffff) {
168        out.extend_from_slice(&frame(MSG_DATA, chunk));
169    }
170    out
171}
172
173/// rsync's file-list order (f_name_cmp at protocol ≥ 29) as a sort key: "." first; within a
174/// directory, non-directories by bytes, then directories by name + "/", each followed by its
175/// subtree.
176pub fn sort_key(path: &str, is_dir: bool) -> Vec<(u8, Vec<u8>)> {
177    if path == "." {
178        return vec![];
179    }
180    let comps: Vec<&[u8]> = path.as_bytes().split(|&b| b == b'/').collect();
181    let mut key = Vec::with_capacity(comps.len());
182    for c in &comps[..comps.len() - 1] {
183        key.push((1, [*c, b"/"].concat()));
184    }
185    let last = comps[comps.len() - 1];
186    key.push(if is_dir {
187        (1, [last, b"/"].concat())
188    } else {
189        (0, last.to_vec())
190    });
191    key
192}
193
194pub fn sort(entries: &mut [Entry]) {
195    entries.sort_by_cached_key(|e| sort_key(&e.path, e.is_dir()));
196}
197
198/// The options a sender cares about, parsed from the argument list.
199#[derive(Debug, Default, Clone)]
200pub struct Request {
201    pub sender: bool,
202    pub recursive: bool,
203    pub dirs: bool,
204    pub links: bool,
205    pub owner: bool,
206    pub group: bool,
207    pub dry_run: bool,
208    pub checksum: bool,
209    pub numeric_ids: bool,
210    pub list_only: bool,
211    /// -D: devices and specials are listed with their rdev.
212    pub devices: bool,
213    /// -H: an uploading client's list would carry device and inode numbers.
214    pub hard_links: bool,
215    pub checksum_seed: Option<i32>,
216    /// Paths after ".", with the module prefix stripped ("" is the module root).
217    pub paths: Vec<String>,
218    /// Why the request cannot be served, in rsync's own words.
219    pub refusal: Option<String>,
220}
221
222/// Parse the daemon argument list (`--server --sender -logDtpr . mod/path`).
223pub fn parse_args(args: &[String], module: &str) -> Request {
224    let mut r = Request::default();
225    let mut paths = false;
226    for a in args {
227        if paths {
228            let p = if a == module {
229                ""
230            } else if let Some(rest) = a.strip_prefix(&format!("{module}/")) {
231                rest
232            } else {
233                r.refusal
234                    .get_or_insert(format!("path {a:?} is outside module {module}"));
235                continue;
236            };
237            r.paths.push(p.to_string());
238            continue;
239        }
240        match a.as_str() {
241            "." => paths = true,
242            "--server" => {}
243            "--sender" => r.sender = true,
244            "--numeric-ids" => r.numeric_ids = true,
245            "--list-only" => r.list_only = true,
246            "--devices" | "--specials" => r.devices = true,
247            // With --delete the uploading client sends a filter list before its file list, and
248            // the receiver is expected to delete what the list does not name; neither applies
249            // to a daemon that stores nothing.
250            s if s.starts_with("--delete") || s == "--del" => {
251                r.refusal
252                    .get_or_insert(format!("{s} is not supported by this daemon"));
253            }
254            s if s.starts_with("--checksum-seed=") => {
255                r.checksum_seed = s["--checksum-seed=".len()..].parse().ok();
256            }
257            s if s.starts_with("--files-from") || s == "--from0" || s == "--secluded-args" => {
258                r.refusal
259                    .get_or_insert(format!("{s} is not supported by this daemon"));
260            }
261            s if s.starts_with("--") => {}
262            s if s.starts_with('-') => {
263                for c in s[1..].chars() {
264                    match c {
265                        'e' => break, // the capability string runs to the end of the cluster
266                        'r' => r.recursive = true,
267                        'd' => r.dirs = true,
268                        'l' => r.links = true,
269                        'o' => r.owner = true,
270                        'g' => r.group = true,
271                        'n' => r.dry_run = true,
272                        'c' => r.checksum = true,
273                        'D' => r.devices = true,
274                        'H' => r.hard_links = true,
275                        'z' | 'U' | 'N' | 'R' | 's' => {
276                            r.refusal
277                                .get_or_insert(format!("-{c} is not supported by this daemon"));
278                        }
279                        _ => {}
280                    }
281                }
282            }
283            _ => {}
284        }
285    }
286    if !r.sender && r.paths.len() > 1 {
287        r.refusal
288            .get_or_insert("an upload names one destination".into());
289    }
290    if !r.sender && r.hard_links {
291        r.refusal
292            .get_or_insert("-H is not supported for uploads by this daemon".into());
293    }
294    r
295}
296
297/// Encodes entries in order, keeping the "previous entry" state both ends share.
298#[derive(Default)]
299pub struct FlistWriter {
300    last_name: Vec<u8>,
301    mode: Option<u32>,
302    mtime: Option<u32>,
303    uid: Option<u32>,
304    gid: Option<u32>,
305}
306
307impl FlistWriter {
308    /// One entry, with the transfer root's flag `top` for "." and named top-level dirs.
309    pub fn entry(&mut self, e: &Entry, top: bool, r: &Request, out: &mut Vec<u8>) {
310        let name = e.path.as_bytes();
311        let first = self.mode.is_none();
312        let mut x: u16 = 0;
313        if e.is_dir() && top {
314            x |= XMIT_TOP_DIR;
315        }
316        if self.mode == Some(e.mode) {
317            x |= XMIT_SAME_MODE;
318        }
319        if !r.owner || (!first && self.uid == Some(e.uid)) {
320            x |= XMIT_SAME_UID;
321        }
322        if !r.group || (!first && self.gid == Some(e.gid)) {
323            x |= XMIT_SAME_GID;
324        }
325        if self.mtime == Some(e.mtime) {
326            x |= XMIT_SAME_TIME;
327        }
328        let l1 = self
329            .last_name
330            .iter()
331            .zip(name)
332            .take(255)
333            .take_while(|(a, b)| a == b)
334            .count();
335        let l2 = name.len() - l1;
336        if l1 > 0 {
337            x |= XMIT_SAME_NAME;
338        }
339        if l2 > 255 {
340            x |= XMIT_LONG_NAME;
341        }
342        if x == 0 && !e.is_dir() {
343            x |= XMIT_TOP_DIR; // filler: a zero flags byte would end the list
344        }
345        if x & 0xff00 != 0 || x == 0 {
346            x |= XMIT_EXTENDED_FLAGS;
347            out.extend_from_slice(&x.to_le_bytes());
348        } else {
349            out.push(x as u8);
350        }
351        if x & XMIT_SAME_NAME != 0 {
352            out.push(l1 as u8);
353        }
354        if x & XMIT_LONG_NAME != 0 {
355            out.extend_from_slice(&le32(l2 as i32));
356        } else {
357            out.push(l2 as u8);
358        }
359        out.extend_from_slice(&name[l1..]);
360        out.extend_from_slice(&longint(e.size));
361        if x & XMIT_SAME_TIME == 0 {
362            out.extend_from_slice(&e.mtime.to_le_bytes());
363        }
364        if x & XMIT_SAME_MODE == 0 {
365            out.extend_from_slice(&e.mode.to_le_bytes());
366        }
367        if r.owner && x & XMIT_SAME_UID == 0 {
368            out.extend_from_slice(&e.uid.to_le_bytes());
369        }
370        if r.group && x & XMIT_SAME_GID == 0 {
371            out.extend_from_slice(&e.gid.to_le_bytes());
372        }
373        if r.links && e.kind == Kind::Symlink {
374            out.extend_from_slice(&le32(e.data.len() as i32));
375            out.extend_from_slice(&e.data);
376        }
377        if r.checksum && e.kind == Kind::File {
378            out.extend_from_slice(&list_sum(&e.data));
379        }
380        self.last_name = name.to_vec();
381        self.mode = Some(e.mode);
382        self.mtime = Some(e.mtime);
383        self.uid = Some(e.uid);
384        self.gid = Some(e.gid);
385    }
386
387    /// End of list, empty id lists for what was asked, and io_error.
388    pub fn finish(r: &Request, io_error: i32, out: &mut Vec<u8>) {
389        out.push(0);
390        if r.owner && !r.numeric_ids {
391            out.extend_from_slice(&le32(0));
392        }
393        if r.group && !r.numeric_ids {
394            out.extend_from_slice(&le32(0));
395        }
396        out.extend_from_slice(&le32(io_error));
397    }
398}
399
400/// What one file list may hold: entries (every entry read counts, including the devices and
401/// specials that are skipped), and name plus symlink-target bytes over all entries.
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403pub struct FlistLimits {
404    pub max_entries: usize,
405    pub max_name_bytes: usize,
406}
407
408impl FlistLimits {
409    /// A list a daemon sends to a downloading client: `MAX_ENTRIES`, each name at most
410    /// `MAX_NAME`.
411    pub const DOWNLOAD: Self = Self {
412        max_entries: MAX_ENTRIES,
413        max_name_bytes: usize::MAX,
414    };
415    /// A list an uploading client sends to the daemon.
416    pub const UPLOAD: Self = Self {
417        max_entries: MAX_UPLOAD_LIST_ENTRIES,
418        max_name_bytes: MAX_UPLOAD_LIST_NAME_BYTES,
419    };
420}
421
422/// A demultiplexing reader: MSG_DATA becomes a byte stream, other messages are collected.
423pub struct MuxReader<R> {
424    inner: R,
425    left: usize,
426    /// False for a stream that is not multiplexed (client to daemon at protocol 29).
427    mux: bool,
428    /// Bytes read from `inner`, frame headers included.
429    pub consumed: u64,
430    pub messages: Vec<(u8, String)>,
431    pub io_error: i32,
432}
433
434impl<R: AsyncRead + Unpin> MuxReader<R> {
435    pub fn new(inner: R) -> Self {
436        Self {
437            inner,
438            left: 0,
439            mux: true,
440            consumed: 0,
441            messages: vec![],
442            io_error: 0,
443        }
444    }
445    /// The same decoders over a stream that carries no multiplexing frames: what a daemon
446    /// reads from an uploading client at protocol 29.
447    pub fn raw(inner: R) -> Self {
448        Self {
449            mux: false,
450            ..Self::new(inner)
451        }
452    }
453    pub async fn read(&mut self, n: usize) -> Result<Vec<u8>> {
454        let mut out = vec![0u8; n];
455        if !self.mux {
456            self.inner
457                .read_exact(&mut out)
458                .await
459                .context("the peer closed the connection")?;
460            self.consumed += n as u64;
461            return Ok(out);
462        }
463        let mut have = 0;
464        while have < n {
465            while self.left == 0 {
466                let mut h = [0u8; 4];
467                self.inner
468                    .read_exact(&mut h)
469                    .await
470                    .context("the daemon closed the connection")?;
471                self.consumed += 4;
472                let h = u32::from_le_bytes(h);
473                let tag = ((h >> 24) as u8).wrapping_sub(MPLEX_BASE);
474                let len = (h & 0x00ff_ffff) as usize;
475                if tag == MSG_DATA {
476                    self.left = len;
477                    continue;
478                }
479                ensure!(len <= 8192, "a {len}-byte rsync message is too long");
480                let mut body = vec![0u8; len];
481                self.inner.read_exact(&mut body).await?;
482                self.consumed += len as u64;
483                match tag {
484                    MSG_ERROR_XFER | MSG_INFO | MSG_ERROR | MSG_WARNING => self
485                        .messages
486                        .push((tag, String::from_utf8_lossy(&body).trim_end().to_string())),
487                    MSG_IO_ERROR if len == 4 => {
488                        self.io_error |= i32::from_le_bytes([body[0], body[1], body[2], body[3]])
489                    }
490                    MSG_NOOP => {}
491                    MSG_ERROR_EXIT => bail!("the daemon exited with an error"),
492                    other => bail!("unexpected rsync message tag {other}"),
493                }
494            }
495            let k = (n - have).min(self.left);
496            self.inner.read_exact(&mut out[have..have + k]).await?;
497            self.consumed += k as u64;
498            have += k;
499            self.left -= k;
500        }
501        Ok(out)
502    }
503    pub async fn byte(&mut self) -> Result<u8> {
504        Ok(self.read(1).await?[0])
505    }
506    pub async fn int(&mut self) -> Result<i32> {
507        let b = self.read(4).await?;
508        Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
509    }
510    pub async fn short(&mut self) -> Result<u16> {
511        let b = self.read(2).await?;
512        Ok(u16::from_le_bytes([b[0], b[1]]))
513    }
514    pub async fn longint(&mut self) -> Result<u64> {
515        let v = self.int().await?;
516        if v != -1 {
517            return Ok(v as u32 as u64);
518        }
519        let b = self.read(8).await?;
520        Ok(i64::from_le_bytes(b.try_into().unwrap_or([0; 8])) as u64)
521    }
522
523    /// Read a file list sent for request `r` (entries in the order sent, plus io_error), within
524    /// [`FlistLimits::DOWNLOAD`].
525    pub async fn file_list(&mut self, r: &Request) -> Result<(Vec<Entry>, i32)> {
526        self.file_list_bounded(r, FlistLimits::DOWNLOAD).await
527    }
528
529    /// Read a file list sent for request `r` within `limits`: past either bound the read stops
530    /// with an error, before the over-limit name or target is read.
531    pub async fn file_list_bounded(
532        &mut self,
533        r: &Request,
534        limits: FlistLimits,
535    ) -> Result<(Vec<Entry>, i32)> {
536        let mut entries = Vec::new();
537        let (mut last, mut mode, mut mtime, mut uid, mut gid) =
538            (Vec::new(), 0u32, 0u32, 0u32, 0u32);
539        let mut listed = 0usize;
540        let mut name_bytes = 0usize;
541        loop {
542            let mut x = u16::from(self.byte().await?);
543            if x == 0 {
544                break;
545            }
546            listed += 1;
547            ensure!(
548                listed <= limits.max_entries,
549                "more than {} file-list entries",
550                limits.max_entries
551            );
552            if x & XMIT_EXTENDED_FLAGS != 0 {
553                x |= u16::from(self.byte().await?) << 8;
554            }
555            let l1 = if x & XMIT_SAME_NAME != 0 {
556                self.byte().await? as usize
557            } else {
558                0
559            };
560            let l2 = if x & XMIT_LONG_NAME != 0 {
561                self.int().await?.max(0) as usize
562            } else {
563                self.byte().await? as usize
564            };
565            ensure!(
566                l1 <= last.len() && l1 + l2 <= MAX_NAME,
567                "a file-list name is malformed"
568            );
569            name_bytes += l1 + l2;
570            ensure!(
571                name_bytes <= limits.max_name_bytes,
572                "more than {} bytes of file-list names",
573                limits.max_name_bytes
574            );
575            let mut name = last[..l1].to_vec();
576            name.extend(self.read(l2).await?);
577            last = name.clone();
578            let size = self.longint().await?;
579            if x & XMIT_SAME_TIME == 0 {
580                mtime = self.int().await? as u32;
581            }
582            if x & XMIT_SAME_MODE == 0 {
583                mode = self.int().await? as u32;
584            }
585            if r.owner && x & XMIT_SAME_UID == 0 {
586                uid = self.int().await? as u32;
587            }
588            if r.group && x & XMIT_SAME_GID == 0 {
589                gid = self.int().await? as u32;
590            }
591            let kind = Kind::of_mode(mode);
592            let mut data = Vec::new();
593            if r.links && kind == Some(Kind::Symlink) {
594                let n = self.int().await?;
595                ensure!(
596                    (0..=MAX_NAME as i32).contains(&n),
597                    "a symlink target is too long"
598                );
599                name_bytes += n as usize;
600                ensure!(
601                    name_bytes <= limits.max_name_bytes,
602                    "more than {} bytes of file-list names",
603                    limits.max_name_bytes
604                );
605                data = self.read(n as usize).await?;
606            }
607            ensure!(
608                kind.is_some() || !r.devices,
609                "a device or special file in the list; -D is not supported for uploads"
610            );
611            if r.checksum && kind == Some(Kind::File) {
612                self.read(16).await?;
613            }
614            let path = String::from_utf8_lossy(&name).to_string();
615            ensure!(
616                !path.starts_with('/') && !path.split('/').any(|c| c == ".."),
617                "unsafe file-list name {path:?}"
618            );
619            // Devices, FIFOs and sockets are listed by mode only without -D; skip them.
620            if let Some(kind) = kind {
621                entries.push(Entry {
622                    path,
623                    kind,
624                    data,
625                    mode,
626                    mtime,
627                    uid,
628                    gid,
629                    size,
630                });
631            }
632        }
633        for wanted in [r.owner, r.group] {
634            if wanted && !r.numeric_ids {
635                loop {
636                    let id = self.int().await?;
637                    if id == 0 {
638                        break;
639                    }
640                    let n = self.byte().await?;
641                    self.read(n as usize).await?;
642                }
643            }
644        }
645        let io_error = self.int().await?;
646        Ok((entries, io_error))
647    }
648}
649
650/// An entry as the model sees it.
651pub fn entry_json(e: &Entry) -> Value {
652    let mut v = json!({
653        "path": e.path,
654        "type": e.kind.name(),
655        "size": e.size,
656        "mode": format!("{:o}", e.mode & 0o7777),
657        "mtime": e.mtime,
658    });
659    if e.kind == Kind::Symlink && !e.data.is_empty() {
660        v["target"] = json!(String::from_utf8_lossy(&e.data));
661    }
662    v
663}
664
665/// Text when it is printable UTF-8, hex otherwise, with the encoding named.
666pub fn content_json(data: &[u8]) -> (String, &'static str) {
667    match std::str::from_utf8(data) {
668        Ok(s)
669            if s.chars()
670                .all(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')) =>
671        {
672            (s.to_string(), "utf8")
673        }
674        _ => (hex::encode(data), "hex"),
675    }
676}
677
678/// One entry the model described: `{path, type, content, encoding, mode, mtime, target, uid, gid}`.
679pub fn entry_from_json(v: &Value, default_mtime: u32) -> Result<Entry> {
680    let path = v["path"]
681        .as_str()
682        .context("each entry needs a path")?
683        .trim_matches('/')
684        .to_string();
685    ensure!(
686        !path.is_empty() && path.len() <= MAX_NAME,
687        "an entry path is empty or too long"
688    );
689    ensure!(
690        !path
691            .split('/')
692            .any(|c| c.is_empty() || c == "." || c == ".."),
693        "entry path {path:?} must be relative, without . or .. components"
694    );
695    let kind = match v["type"].as_str().unwrap_or("file") {
696        "file" => Kind::File,
697        "dir" | "directory" => Kind::Dir,
698        "symlink" | "link" => Kind::Symlink,
699        other => bail!("entry type {other:?} is not file, dir or symlink"),
700    };
701    let data = match kind {
702        Kind::File => {
703            let content = v["content"].as_str().unwrap_or_default();
704            match v["encoding"].as_str().unwrap_or("utf8") {
705                "utf8" => content.as_bytes().to_vec(),
706                "hex" => hex::decode(content).context("content is not hex")?,
707                other => bail!("encoding {other:?} is not utf8 or hex"),
708            }
709        }
710        Kind::Symlink => v["target"]
711            .as_str()
712            .context("a symlink needs a target")?
713            .as_bytes()
714            .to_vec(),
715        Kind::Dir => vec![],
716    };
717    let type_bits = match kind {
718        Kind::File => S_IFREG,
719        Kind::Dir => S_IFDIR,
720        Kind::Symlink => S_IFLNK,
721    };
722    let perms = match v.get("mode") {
723        None | Some(Value::Null) => match kind {
724            Kind::File => 0o644,
725            Kind::Dir => 0o755,
726            Kind::Symlink => 0o777,
727        },
728        Some(Value::String(s)) => u32::from_str_radix(s.trim_start_matches("0o"), 8)
729            .with_context(|| format!("mode {s:?} is not octal"))?,
730        Some(n) => n
731            .as_u64()
732            .context("mode is an octal string such as \"644\"")? as u32,
733    };
734    ensure!(
735        perms <= 0o7777,
736        "mode {perms:o} has bits beyond permissions"
737    );
738    let mtime = v["mtime"]
739        .as_u64()
740        .map(|t| t as u32)
741        .unwrap_or(default_mtime);
742    let size = match kind {
743        Kind::Dir => 4096,
744        _ => data.len() as u64,
745    };
746    Ok(Entry {
747        path,
748        kind,
749        data,
750        mode: type_bits | perms,
751        mtime,
752        uid: v["uid"].as_u64().unwrap_or(0) as u32,
753        gid: v["gid"].as_u64().unwrap_or(0) as u32,
754        size,
755    })
756}