1mod protocol;
4#[cfg(all(test, unix))]
5mod tests;
6
7use crate::ReadLimit;
8use ropey::Rope;
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use strop_core::worker::CancelToken;
12use strop_workspace::RemoteFile;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15struct ContentDigest([u8; 32]);
16impl ContentDigest {
17 fn of(text: &Rope) -> Self {
18 let mut hash = Sha256::new();
19 for chunk in text.chunks() {
20 hash.update(chunk.as_bytes());
21 }
22 Self(hash.finalize().into())
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RemoteVersion {
29 file: RemoteFile,
30 stamp: Stamp,
31}
32impl RemoteVersion {
33 pub fn file(&self) -> &RemoteFile {
34 &self.file
35 }
36 pub fn size(&self) -> crate::RemoteSize {
37 crate::RemoteSize::new(self.stamp.size)
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct Stamp {
44 device: u64,
45 inode: u64,
46 size: u64,
47 mtime_ns: i64,
48 ctime_ns: i64,
49 mode: u32,
50 uid: u32,
51 gid: u32,
52 content: ContentDigest,
53 attributes: ContentDigest,
54}
55impl Stamp {
56 fn valid(&self) -> bool {
57 self.size <= ReadLimit::MAX && self.mode <= 0o7777
58 }
59 fn preserves(&self, before: &Self) -> bool {
60 self.mode == before.mode
61 && self.uid == before.uid
62 && self.gid == before.gid
63 && self.mtime_ns == before.mtime_ns
64 && self.attributes == before.attributes
65 && self.device == before.device
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RemoteSaveReceipt {
71 version: RemoteVersion,
72}
73impl RemoteSaveReceipt {
74 pub fn into_version(self) -> RemoteVersion {
75 self.version
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub enum Verification {
81 Unchanged(RemoteVersion),
82 Written(RemoteSaveReceipt),
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum RefusalKind {
88 Conflict,
89 Busy,
90 Unsupported,
91 Permission,
92 InvalidPath,
93 TooLarge,
94 Metadata,
95 Protocol,
96 Io,
97 Cancelled,
98}
99impl std::fmt::Display for RefusalKind {
100 fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 out.write_str(match self {
102 Self::Conflict => "remote file changed",
103 Self::Busy => "remote file busy",
104 Self::Unsupported => "unsupported remote save capability",
105 Self::Permission => "remote permission denied",
106 Self::InvalidPath => "remote path refused",
107 Self::TooLarge => "remote snapshot too large",
108 Self::Metadata => "remote metadata cannot be preserved",
109 Self::Protocol => "invalid remote save response",
110 Self::Io => "remote I/O failed",
111 Self::Cancelled => "remote operation cancelled before commit",
112 })
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
117pub enum RemoteSaveError {
118 #[error("{kind}: {detail}")]
119 Refused { kind: RefusalKind, detail: String },
120 #[error("remote save outcome unconfirmed: {detail}; use :remote verify")]
121 Unconfirmed { detail: String },
122}
123impl RemoteSaveError {
124 fn refused(kind: RefusalKind, detail: impl Into<String>) -> Self {
125 Self::Refused {
126 kind,
127 detail: detail.into(),
128 }
129 }
130 pub fn is_unconfirmed(&self) -> bool {
131 matches!(self, Self::Unconfirmed { .. })
132 }
133}
134
135fn checked_length(contents: &Rope) -> Result<u64, RemoteSaveError> {
136 let length = contents.len_bytes() as u64;
137 if length > ReadLimit::MAX {
138 Err(RemoteSaveError::refused(
139 RefusalKind::TooLarge,
140 "edited content exceeds the 256 MiB bound",
141 ))
142 } else {
143 Ok(length)
144 }
145}
146
147pub fn prepare_edit(
150 file: &RemoteFile,
151 contents: &Rope,
152 token: &CancelToken,
153) -> Result<RemoteVersion, RemoteSaveError> {
154 let length = checked_length(contents)?;
155 let digest = ContentDigest::of(contents);
156 let reply = protocol::invoke(
157 file,
158 protocol::Operation::Edit {
159 length,
160 digest: &digest,
161 },
162 token,
163 )?;
164 let protocol::Reply::Ready { stamp } = reply else {
165 return Err(RemoteSaveError::refused(
166 RefusalKind::Protocol,
167 "expected an edit baseline",
168 ));
169 };
170 if !stamp.valid() || stamp.size != length || stamp.content != digest {
171 return Err(RemoteSaveError::refused(
172 RefusalKind::Protocol,
173 "baseline does not match the displayed snapshot",
174 ));
175 }
176 Ok(RemoteVersion {
177 file: file.clone(),
178 stamp,
179 })
180}
181
182pub fn prepare_relocated_edit(
186 file: &RemoteFile,
187 before: &RemoteVersion,
188 token: &CancelToken,
189) -> Result<RemoteVersion, RemoteSaveError> {
190 if file.endpoint() != before.file.endpoint() {
191 return Err(RemoteSaveError::refused(
192 RefusalKind::Conflict,
193 "relocation crossed an endpoint",
194 ));
195 }
196 let reply = protocol::invoke(
197 file,
198 protocol::Operation::Edit {
199 length: before.stamp.size,
200 digest: &before.stamp.content,
201 },
202 token,
203 )?;
204 let protocol::Reply::Ready { stamp } = reply else {
205 return Err(RemoteSaveError::refused(
206 RefusalKind::Protocol,
207 "expected a fresh edit baseline",
208 ));
209 };
210 if !stamp.valid()
211 || stamp.inode != before.stamp.inode
212 || !stamp.preserves(&before.stamp)
213 || stamp.size != before.stamp.size
214 || stamp.content != before.stamp.content
215 {
216 return Err(RemoteSaveError::refused(
217 RefusalKind::Conflict,
218 "relocated file no longer matches the stored source baseline",
219 ));
220 }
221 Ok(RemoteVersion {
222 file: file.clone(),
223 stamp,
224 })
225}
226
227pub fn save(
230 before: &RemoteVersion,
231 contents: &Rope,
232 token: &CancelToken,
233) -> Result<RemoteSaveReceipt, RemoteSaveError> {
234 let length = checked_length(contents)?;
235 let digest = ContentDigest::of(contents);
236 let reply = protocol::invoke(
237 &before.file,
238 protocol::Operation::Save {
239 before: &before.stamp,
240 length,
241 digest: &digest,
242 contents,
243 },
244 token,
245 )?;
246 let protocol::Reply::Written { stamp } = reply else {
247 return Err(RemoteSaveError::Unconfirmed {
248 detail: "expected a durable save receipt".into(),
249 });
250 };
251 receipt(before, stamp, &digest, length)
252}
253
254pub fn verify(
257 before: &RemoteVersion,
258 intended: &Rope,
259 token: &CancelToken,
260) -> Result<Verification, RemoteSaveError> {
261 let length = checked_length(intended)?;
262 let digest = ContentDigest::of(intended);
263 match protocol::invoke(
264 &before.file,
265 protocol::Operation::Verify {
266 before: &before.stamp,
267 length,
268 digest: &digest,
269 },
270 token,
271 )? {
272 protocol::Reply::Unchanged { stamp } if stamp == before.stamp => {
273 Ok(Verification::Unchanged(before.clone()))
274 }
275 protocol::Reply::Written { stamp } => {
276 receipt(before, stamp, &digest, length).map(Verification::Written)
277 }
278 _ => Err(RemoteSaveError::Unconfirmed {
279 detail: "verification returned an inconsistent state".into(),
280 }),
281 }
282}
283
284fn receipt(
285 before: &RemoteVersion,
286 stamp: Stamp,
287 digest: &ContentDigest,
288 length: u64,
289) -> Result<RemoteSaveReceipt, RemoteSaveError> {
290 if !stamp.valid()
291 || stamp.size != length
292 || stamp.content != *digest
293 || !stamp.preserves(&before.stamp)
294 {
295 return Err(RemoteSaveError::Unconfirmed {
296 detail: "receipt does not match the intended bytes and metadata".into(),
297 });
298 }
299 Ok(RemoteSaveReceipt {
300 version: RemoteVersion {
301 file: before.file.clone(),
302 stamp,
303 },
304 })
305}