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, 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/// An opaque baseline binds one file to its content and filesystem metadata.
26#[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
146/// Worker-only admission. Nothing grants editability until these exact displayed
147/// bytes match the remote file and its no-follow/metadata capabilities are checked.
148pub 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
181/// Worker-only conditional atomic replacement. The file is carried by its baseline,
182/// so a caller cannot accidentally pair a different path with the expected version.
183pub 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
208/// Explicit reconciliation after an ambiguous result. A matching intended state
209/// is synced again before acknowledgment; this does not prove historical authorship.
210pub 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}