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/// Worker-only conditional atomic replacement. The file is carried by its baseline,
183/// so a caller cannot accidentally pair a different path with the expected version.
184pub 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
209/// Explicit reconciliation after an ambiguous result. A matching intended state
210/// is synced again before acknowledgment; this does not prove historical authorship.
211pub 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}