Skip to main content

strop_remote/
selection.rs

1//! Typed byte-domain selection and window metadata. Every quantity here is
2//! bytes on the remote file — never lines, characters or "units" — and the
3//! constructors are the only way to build one, so an invalid length or a
4//! raw `u64` confused for an offset cannot reach a read.
5
6use serde::{Deserialize, Serialize};
7
8/// A byte offset into a remote file. Untyped in memory, typed at the API.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct RemoteOffset(u64);
12
13impl RemoteOffset {
14    pub const fn new(value: u64) -> Self {
15        Self(value)
16    }
17
18    pub const fn get(self) -> u64 {
19        self.0
20    }
21}
22
23/// Why a [`ReadLimit`] was refused: the byte-domain bounds exist so one
24/// snapshot can never ask for an unbounded or empty allocation.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
26#[error("read lengths must be between 1 byte and {} bytes", ReadLimit::MAX)]
27pub struct ReadLimitError;
28
29/// A checked read length in bytes: positive and at most [`ReadLimit::MAX`],
30/// the in-memory snapshot cap.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(try_from = "u64", into = "u64")]
33pub struct ReadLimit(u64);
34
35impl ReadLimit {
36    /// Largest window one snapshot may allocate (256 MiB).
37    pub const MAX: u64 = 256 * 1024 * 1024;
38    /// The default tail window (256 KiB), also follow's initial window.
39    pub const DEFAULT_TAIL: Self = Self(256 * 1024);
40
41    /// Admit one length; zero and anything above [`ReadLimit::MAX`] are
42    /// refused before any transfer or allocation.
43    pub fn new(value: u64) -> Result<Self, ReadLimitError> {
44        if value == 0 || value > Self::MAX {
45            Err(ReadLimitError)
46        } else {
47            Ok(Self(value))
48        }
49    }
50
51    pub const fn get(self) -> u64 {
52        self.0
53    }
54}
55impl TryFrom<u64> for ReadLimit {
56    type Error = ReadLimitError;
57    fn try_from(value: u64) -> Result<Self, Self::Error> {
58        Self::new(value)
59    }
60}
61impl From<ReadLimit> for u64 {
62    fn from(value: ReadLimit) -> Self {
63        value.get()
64    }
65}
66
67/// A whole-file size in bytes, as captured at inspection time.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69#[serde(transparent)]
70pub struct RemoteSize(u64);
71
72impl RemoteSize {
73    pub const fn new(value: u64) -> Self {
74        Self(value)
75    }
76
77    pub const fn get(self) -> u64 {
78        self.0
79    }
80}
81
82/// Which bytes one read wants.
83///
84/// `Full` is `[0, inspected_size)`. `Range` starts at `start` and carries at
85/// most `length` bytes, clamped to the inspected end. `Tail` names the last
86/// `length` bytes (the whole file when it is shorter). A selection resolved
87/// against the inspected size yields a [`RemoteWindow`]; growth after
88/// inspection is not followed.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
90pub enum ReadSelection {
91    Full,
92    Range {
93        start: RemoteOffset,
94        length: ReadLimit,
95    },
96    Tail(ReadLimit),
97}
98
99/// The bytes a snapshot actually covers: where the content starts, how many
100/// bytes it holds, and how large the whole file was when inspected. The
101/// buffer contains exactly the window — nothing more is implied.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
103pub struct RemoteWindow {
104    start: RemoteOffset,
105    length: RemoteSize,
106    file_size: RemoteSize,
107}
108
109impl RemoteWindow {
110    /// Resolve a selection against an inspected size. Every branch is
111    /// clamped with checked arithmetic; an empty result is represented, not
112    /// refused.
113    pub fn resolve(selection: &ReadSelection, file_size: RemoteSize) -> Self {
114        let size = file_size.get();
115        let (start, length) = match selection {
116            ReadSelection::Full => (0, size),
117            ReadSelection::Range { start, length } => {
118                let start = start.get().min(size);
119                (start, length.get().min(size - start))
120            }
121            ReadSelection::Tail(length) => {
122                let start = size.saturating_sub(length.get());
123                (start, size - start)
124            }
125        };
126        Self {
127            start: RemoteOffset::new(start),
128            length: RemoteSize::new(length),
129            file_size,
130        }
131    }
132
133    /// Where the covered bytes begin.
134    pub fn start(&self) -> RemoteOffset {
135        self.start
136    }
137
138    /// How many bytes the snapshot holds.
139    pub fn length(&self) -> RemoteSize {
140        self.length
141    }
142
143    /// The whole-file size captured at inspection time.
144    pub fn file_size(&self) -> RemoteSize {
145        self.file_size
146    }
147
148    /// True only when the snapshot covers the entire inspected file.
149    pub fn is_complete(&self) -> bool {
150        self.start.get() == 0 && self.length == self.file_size
151    }
152
153    /// True when this window ends at the inspected end of file — the
154    /// position a tail/follow window must keep.
155    pub(super) fn reaches_eof(&self) -> bool {
156        self.start.get() + self.length.get() == self.file_size.get()
157    }
158
159    /// The same window narrowed to the boundary-aligned bytes actually
160    /// held: the start advances by `front` and the length becomes `kept`.
161    pub(super) fn narrowed(self, front: u64, kept: u64) -> Self {
162        Self {
163            start: RemoteOffset::new(self.start.get() + front),
164            length: RemoteSize::new(kept),
165            file_size: self.file_size,
166        }
167    }
168}
169
170/// The boundary-aligned sub-range of `bytes` that holds only complete UTF-8
171/// sequences at its edges: a window may start or end mid-sequence, and the
172/// snapshot trims those partial edges rather than emitting invalid text or
173/// dropping the whole window. Interior invalid bytes are left in place —
174/// UTF-8 validation still fails honestly on them.
175pub(super) fn utf8_boundary_range(bytes: &[u8]) -> (usize, usize) {
176    let mut start = 0;
177    // A leading partial sequence is at most three continuation bytes.
178    while start < bytes.len() && start < 3 && is_continuation(bytes[start]) {
179        start += 1;
180    }
181    let mut end = bytes.len();
182    // Find the start of the last sequence and drop it if it is cut short.
183    let last = bytes[start..]
184        .iter()
185        .rposition(|&byte| !is_continuation(byte))
186        .map(|position| start + position);
187    if let Some(lead) = last {
188        let expected = sequence_length(bytes[lead]);
189        if lead + expected > end {
190            end = lead;
191        }
192    }
193    (start, end)
194}
195
196fn is_continuation(byte: u8) -> bool {
197    byte & 0xC0 == 0x80
198}
199
200/// Length of a sequence from its leading byte; 1 for anything that is not a
201/// valid leading byte (validation reports those separately).
202fn sequence_length(lead: u8) -> usize {
203    match lead {
204        0xC2..=0xDF => 2,
205        0xE0..=0xEF => 3,
206        0xF0..=0xF4 => 4,
207        _ => 1,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn window(selection: &ReadSelection, size: u64) -> RemoteWindow {
216        RemoteWindow::resolve(selection, RemoteSize::new(size))
217    }
218
219    #[test]
220    fn limits_are_checked_at_construction() {
221        assert!(ReadLimit::new(1).is_ok());
222        assert_eq!(ReadLimit::new(512).unwrap().get(), 512);
223        assert_eq!(
224            ReadLimit::new(ReadLimit::MAX).unwrap().get(),
225            ReadLimit::MAX
226        );
227        assert!(ReadLimit::new(0).is_err());
228        assert!(ReadLimit::new(ReadLimit::MAX + 1).is_err());
229    }
230
231    #[test]
232    fn full_covers_everything() {
233        let whole = window(&ReadSelection::Full, 4096);
234        assert_eq!(whole.start().get(), 0);
235        assert_eq!(whole.length().get(), 4096);
236        assert_eq!(whole.file_size().get(), 4096);
237        assert!(whole.is_complete());
238        assert!(whole.reaches_eof());
239        let empty_file = window(&ReadSelection::Full, 0);
240        assert!(empty_file.is_complete());
241        assert_eq!(empty_file.length().get(), 0);
242    }
243
244    #[test]
245    fn ranges_clamp_to_the_inspected_end() {
246        let selection = ReadSelection::Range {
247            start: RemoteOffset::new(100),
248            length: ReadLimit::new(50).unwrap(),
249        };
250        let clamped = window(&selection, 120);
251        assert_eq!(clamped.start().get(), 100);
252        assert_eq!(clamped.length().get(), 20);
253        assert!(!clamped.is_complete());
254        assert!(clamped.reaches_eof());
255        // A start beyond EOF clamps to an empty window at EOF.
256        let past = window(
257            &ReadSelection::Range {
258                start: RemoteOffset::new(500),
259                length: ReadLimit::new(10).unwrap(),
260            },
261            100,
262        );
263        assert_eq!(past.start().get(), 100);
264        assert_eq!(past.length().get(), 0);
265        // A zero-length result is represented, never refused.
266        let zero = window(
267            &ReadSelection::Range {
268                start: RemoteOffset::new(10),
269                length: ReadLimit::new(1).unwrap(),
270            },
271            10,
272        );
273        assert_eq!(zero.length().get(), 0);
274        assert_eq!(zero.start().get(), 10);
275    }
276
277    #[test]
278    fn tails_stop_at_the_start_of_file() {
279        let selection = ReadSelection::Tail(ReadLimit::new(100).unwrap());
280        let tail = window(&selection, 1000);
281        assert_eq!(tail.start().get(), 900);
282        assert_eq!(tail.length().get(), 100);
283        assert!(tail.reaches_eof());
284        assert!(!tail.is_complete());
285        let short = window(&selection, 40);
286        assert_eq!(short.start().get(), 0);
287        assert_eq!(short.length().get(), 40);
288        assert!(short.is_complete());
289        let empty = window(
290            &ReadSelection::Tail(ReadLimit::new(ReadLimit::MAX).unwrap()),
291            0,
292        );
293        assert_eq!(empty.length().get(), 0);
294    }
295
296    #[test]
297    fn deserialization_cannot_bypass_read_admission() {
298        assert!(serde_json::from_str::<ReadLimit>("0").is_err());
299        assert!(serde_json::from_str::<ReadLimit>(&((ReadLimit::MAX + 1).to_string())).is_err());
300    }
301}