Skip to main content

strop_remote/save/
mod.rs

1//! Explicit remote editing. Atomic replacement and cooperative locking are owned
2//! by the shipped helper; nonparticipating writers are not excluded by flock.
3mod 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/// An opaque baseline binds one file to its content and filesystem metadata.
27#[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
147/// Worker-only admission. Nothing grants editability until these exact displayed
148/// bytes match the remote file and its no-follow/metadata capabilities are checked.
149pub 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
182/// Explicit worker-only re-admission after a confirmed same-object relocation.
183/// The old version is evidence of stored bytes, not transferable write authority.
184/// A fresh protected helper check must prove the new name still owns that object.
185pub 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
227/// Worker-only conditional atomic replacement. The file is carried by its baseline,
228/// so a caller cannot accidentally pair a different path with the expected version.
229pub 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
254/// Explicit reconciliation after an ambiguous result. A matching intended state
255/// is synced again before acknowledgment; this does not prove historical authorship.
256pub 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}