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 save(
185 before: &RemoteVersion,
186 contents: &Rope,
187 token: &CancelToken,
188) -> Result<RemoteSaveReceipt, RemoteSaveError> {
189 let length = checked_length(contents)?;
190 let digest = ContentDigest::of(contents);
191 let reply = protocol::invoke(
192 &before.file,
193 protocol::Operation::Save {
194 before: &before.stamp,
195 length,
196 digest: &digest,
197 contents,
198 },
199 token,
200 )?;
201 let protocol::Reply::Written { stamp } = reply else {
202 return Err(RemoteSaveError::Unconfirmed {
203 detail: "expected a durable save receipt".into(),
204 });
205 };
206 receipt(before, stamp, &digest, length)
207}
208
209pub fn verify(
212 before: &RemoteVersion,
213 intended: &Rope,
214 token: &CancelToken,
215) -> Result<Verification, RemoteSaveError> {
216 let length = checked_length(intended)?;
217 let digest = ContentDigest::of(intended);
218 match protocol::invoke(
219 &before.file,
220 protocol::Operation::Verify {
221 before: &before.stamp,
222 length,
223 digest: &digest,
224 },
225 token,
226 )? {
227 protocol::Reply::Unchanged { stamp } if stamp == before.stamp => {
228 Ok(Verification::Unchanged(before.clone()))
229 }
230 protocol::Reply::Written { stamp } => {
231 receipt(before, stamp, &digest, length).map(Verification::Written)
232 }
233 _ => Err(RemoteSaveError::Unconfirmed {
234 detail: "verification returned an inconsistent state".into(),
235 }),
236 }
237}
238
239fn receipt(
240 before: &RemoteVersion,
241 stamp: Stamp,
242 digest: &ContentDigest,
243 length: u64,
244) -> Result<RemoteSaveReceipt, RemoteSaveError> {
245 if !stamp.valid()
246 || stamp.size != length
247 || stamp.content != *digest
248 || !stamp.preserves(&before.stamp)
249 {
250 return Err(RemoteSaveError::Unconfirmed {
251 detail: "receipt does not match the intended bytes and metadata".into(),
252 });
253 }
254 Ok(RemoteSaveReceipt {
255 version: RemoteVersion {
256 file: before.file.clone(),
257 stamp,
258 },
259 })
260}