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