1use ropey::Rope;
4#[cfg(all(test, target_os = "linux"))]
5mod tests;
6use serde::{de::DeserializeOwned, Deserialize};
7use serde_json::{json, Value};
8use sha2::{Digest, Sha256};
9use strop_core::worker::CancelToken;
10use strop_workspace::operation::*;
11use strop_workspace::{Filesystem, Observation, RemoteEndpoint, ResourceLocation};
12
13const HELPER: &str = concat!(
14 include_str!("../protected.py"),
15 "\n",
16 include_str!("observe.py"),
17 "\n",
18 include_str!("cleanup.py"),
19 "\n",
20 include_str!("mutate.py"),
21 "\n",
22 include_str!("main.py")
23);
24const HEADER_LIMIT: usize = 64 * 1024;
25const REPLY_LIMIT: usize = 64 * 1024;
26const BODY_LIMIT: usize = 256 * 1024 * 1024;
27
28fn failure(kind: FsFailureKind, detail: impl Into<String>) -> FsFailure {
29 FsFailure::new(kind, detail)
30}
31fn endpoint(intent: &OperationIntent) -> Result<&RemoteEndpoint, FsFailure> {
32 let location = intent.location().ok_or_else(|| {
33 failure(
34 FsFailureKind::InvalidPath,
35 "filesystem operation has no resource",
36 )
37 })?;
38 let Filesystem::Remote(endpoint) = &location.filesystem else {
39 return Err(failure(
40 FsFailureKind::Unsupported,
41 "protected SSH operations require a remote namespace",
42 ));
43 };
44 if intent
45 .source
46 .iter()
47 .chain(intent.destination.iter())
48 .any(|other| other.filesystem != location.filesystem)
49 {
50 return Err(failure(
51 FsFailureKind::Unsupported,
52 "cross-namespace transfer is unsupported",
53 ));
54 }
55 Ok(endpoint)
56}
57fn path(location: Option<&ResourceLocation>) -> Option<&[u8]> {
58 location.map(|location| strop_workspace::addr::uri::path_bytes(&location.path))
59}
60
61#[derive(Deserialize)]
62struct NativeObservation {
63 path: Vec<u8>,
64 value: Option<Observation>,
65}
66impl NativeObservation {
67 fn locate(self, endpoint: &RemoteEndpoint) -> Result<LocatedObservation, FsFailure> {
68 let path = strop_workspace::addr::uri::bytes_to_path(self.path)
69 .map_err(|error| failure(FsFailureKind::Protocol, error.to_string()))?;
70 let file = strop_workspace::RemoteFile::from_path(endpoint.clone(), path)
71 .map_err(|error| failure(FsFailureKind::Protocol, error.to_string()))?;
72 Ok(LocatedObservation {
73 location: ResourceLocation::remote(endpoint.clone(), file.path().to_owned()),
74 value: self.value,
75 })
76 }
77}
78#[derive(Deserialize)]
79struct PreparedReply {
80 source: Option<NativeObservation>,
81 destination: Option<NativeObservation>,
82 parents: Vec<NativeObservation>,
83 capability: OperationCapability,
84}
85#[derive(Deserialize)]
86struct Reply<T> {
87 version: u8,
88 value: Option<T>,
89 error: Option<HelperError>,
90}
91#[derive(Deserialize)]
92struct HelperError {
93 kind: String,
94 detail: String,
95 unconfirmed: bool,
96 observed_destination: Option<Observation>,
97 publication: Option<PublicationWitness>,
98}
99impl HelperError {
100 fn failure(self) -> FsFailure {
101 let kind = match self.kind.as_str() {
102 "unsupported" => FsFailureKind::Unsupported,
103 "permission" => FsFailureKind::Permission,
104 "conflict" => FsFailureKind::Conflict,
105 "busy" => FsFailureKind::Busy,
106 "invalid_path" => FsFailureKind::InvalidPath,
107 "incomplete" => FsFailureKind::Incomplete,
108 "cancelled" => FsFailureKind::Cancelled,
109 "io" => FsFailureKind::Io,
110 _ => FsFailureKind::Protocol,
111 };
112 failure(kind, self.detail)
113 }
114}
115
116enum InvokeError {
117 Before(FsFailure),
118 Unconfirmed {
119 detail: String,
120 observed_destination: Option<Box<Observation>>,
121 publication: Option<PublicationWitness>,
122 },
123}
124fn invoke<T: DeserializeOwned>(
125 endpoint: &RemoteEndpoint,
126 request: Value,
127 contents: Option<&Rope>,
128 mutation: bool,
129 token: &CancelToken,
130) -> Result<T, InvokeError> {
131 if token.is_cancelled() {
132 return Err(InvokeError::Before(failure(
133 FsFailureKind::Cancelled,
134 "cancelled before helper launch",
135 )));
136 }
137 let mut header = serde_json::to_vec(&request).map_err(|error| {
138 InvokeError::Before(failure(FsFailureKind::Protocol, error.to_string()))
139 })?;
140 header.push(b'\n');
141 if header.len() > HEADER_LIMIT {
142 return Err(InvokeError::Before(failure(
143 FsFailureKind::Unsupported,
144 "filesystem request header exceeds its bound",
145 )));
146 }
147 let command = crate::RemoteCommand::python(HELPER, Vec::new(), std::path::Path::new("/"))
148 .map_err(|error| {
149 InvokeError::Before(failure(FsFailureKind::Unsupported, error.to_string()))
150 })?;
151 let mut chunks = vec![header.as_slice()];
152 if let Some(contents) = contents {
153 chunks.extend(contents.chunks().map(str::as_bytes));
154 }
155 let lost = |detail: String| {
156 if mutation {
157 InvokeError::Unconfirmed {
158 detail,
159 observed_destination: None,
160 publication: None,
161 }
162 } else {
163 InvokeError::Before(failure(FsFailureKind::Io, detail))
164 }
165 };
166 let output = crate::run_with_input(endpoint, &command, token, &chunks)
167 .map_err(|error| lost(error.to_string()))?;
168 if output.stdout_dropped != 0 || output.stdout.len() > REPLY_LIMIT {
169 return Err(lost("filesystem response exceeded its bound".into()));
170 }
171 let reply: Reply<T> = serde_json::from_slice(&output.stdout).map_err(|error| {
172 lost(format!(
173 "{error}; {}",
174 String::from_utf8_lossy(&output.stderr)
175 ))
176 })?;
177 if reply.version != 1 {
178 return Err(lost("filesystem protocol version differs".into()));
179 }
180 if let Some(error) = reply.error {
181 return Err(if error.unconfirmed {
182 InvokeError::Unconfirmed {
183 detail: error.detail,
184 observed_destination: error.observed_destination.map(Box::new),
185 publication: error.publication,
186 }
187 } else {
188 InvokeError::Before(error.failure())
189 });
190 }
191 if !output.status.success() || output.stdin_error.is_some() {
192 return Err(lost(format!(
193 "filesystem helper transport did not complete: {:?}; {:?}",
194 output.status, output.stdin_error
195 )));
196 }
197 reply
198 .value
199 .ok_or_else(|| lost("filesystem response omitted its result".into()))
200}
201fn read_error(error: InvokeError) -> FsFailure {
202 match error {
203 InvokeError::Before(error) => error,
204 InvokeError::Unconfirmed { detail, .. } => failure(FsFailureKind::Protocol, detail),
205 }
206}
207
208pub fn prepare(
209 intent: &OperationIntent,
210 allow_occupied: bool,
211 token: &CancelToken,
212) -> Result<PreparedOperation, FsFailure> {
213 let endpoint = endpoint(intent)?;
214 let buffer_copy =
215 intent.kind == OperationKind::Copy && intent.copy_version == CopyVersion::Buffer;
216 let reply: PreparedReply = invoke(endpoint, json!({"version":1,"action":"prepare","kind":intent.kind,
217 "source":path(intent.source.as_ref()),"destination":path(intent.destination.as_ref()),
218 "buffer_copy":buffer_copy,"allow_occupied":allow_occupied,"expected_content":intent.expected_content}), None, false, token).map_err(read_error)?;
219 if reply.parents.len() > 2 {
220 return Err(failure(
221 FsFailureKind::Protocol,
222 "helper returned too many parent observations",
223 ));
224 }
225 Ok(PreparedOperation {
226 intent: intent.clone(),
227 source: reply
228 .source
229 .map(|value| value.locate(endpoint))
230 .transpose()?,
231 destination: reply
232 .destination
233 .map(|value| value.locate(endpoint))
234 .transpose()?,
235 parents: reply
236 .parents
237 .into_iter()
238 .map(|value| value.locate(endpoint))
239 .collect::<Result<_, _>>()?,
240 dependencies: Vec::new(),
241 capability: reply.capability,
242 })
243}
244
245fn request(operation: &PreparedOperation, action: &str) -> Value {
246 json!({"version":1,"action":action,"kind":operation.intent.kind,
247 "source":path(operation.source.as_ref().map(|value| &value.location)),
248 "destination":path(operation.destination.as_ref().map(|value| &value.location)),
249 "logical_source":path(operation.intent.source.as_ref()),"logical_destination":path(operation.intent.destination.as_ref()),
250 "before_source":operation.source.as_ref().and_then(|value| value.value.as_ref()),
251 "before_destination":operation.destination.as_ref().and_then(|value| value.value.as_ref()),
252 "parents":operation.parents.iter().map(|parent| json!({"path":path(Some(&parent.location)),"value":parent.value})).collect::<Vec<_>>(),
253 "capability":operation.capability,"buffer_copy":operation.intent.copy_version == CopyVersion::Buffer})
254}
255
256pub fn execute(
257 operation: &PreparedOperation,
258 contents: Option<&Rope>,
259 receipts: &[StepReceipt],
260 token: &CancelToken,
261) -> StepOutcome {
262 let endpoint = match endpoint(&operation.intent) {
263 Ok(endpoint) => endpoint,
264 Err(error) => return StepOutcome::Refused(error),
265 };
266 let mut request = request(operation, "apply");
267 if operation.intent.kind == OperationKind::Copy
268 && operation.intent.copy_version == CopyVersion::Buffer
269 {
270 let Some(contents) = contents else {
271 return StepOutcome::Refused(failure(
272 FsFailureKind::Conflict,
273 "buffer copy snapshot is missing",
274 ));
275 };
276 if contents.len_bytes() > BODY_LIMIT {
277 return StepOutcome::Refused(failure(
278 FsFailureKind::Unsupported,
279 "buffer copy exceeds the 256 MiB upload bound",
280 ));
281 }
282 let mut digest = Sha256::new();
283 for chunk in contents.chunks() {
284 digest.update(chunk.as_bytes());
285 }
286 let digest: [u8; 32] = digest.finalize().into();
287 request["length"] = json!(contents.len_bytes());
288 request["digest"] = json!(digest);
289 }
290 request["parents"] = Value::Array(
291 operation
292 .parents
293 .iter()
294 .map(|parent| {
295 let value = parent.value.as_ref().or_else(|| {
296 operation.dependencies.iter().find_map(|step| {
297 receipts.iter().find_map(|receipt| {
298 if receipt.step != *step
299 || receipt
300 .operation
301 .destination
302 .as_ref()
303 .map(|value| &value.location)
304 != Some(&parent.location)
305 {
306 return None;
307 }
308 match &receipt.outcome {
309 StepOutcome::Committed {
310 destination_after, ..
311 } => destination_after.as_ref(),
312 _ => None,
313 }
314 })
315 })
316 });
317 json!({"path":path(Some(&parent.location)),"value":value})
318 })
319 .collect(),
320 );
321 let vacated = operation.destination.as_ref().is_some_and(|destination| {
322 destination.value.as_ref().is_some_and(|expected| {
323 operation.dependencies.iter().any(|step| {
324 receipts.iter().any(|receipt| {
325 receipt.step == *step
326 && receipt.outcome.is_committed()
327 && receipt.operation.source.as_ref().is_some_and(|source| {
328 source.location == destination.location
329 && source
330 .value
331 .as_ref()
332 .is_some_and(|value| value.same_object(expected))
333 })
334 })
335 })
336 })
337 });
338 request["vacated"] = json!(vacated);
339 let contents = contents.filter(|_| {
340 operation.intent.kind == OperationKind::Copy
341 && operation.intent.copy_version == CopyVersion::Buffer
342 });
343 match invoke(endpoint, request, contents, true, token) {
344 Ok(outcome) => outcome,
345 Err(InvokeError::Before(error)) if error.kind == FsFailureKind::Cancelled => {
346 StepOutcome::Cancelled {
347 detail: error.detail,
348 }
349 }
350 Err(InvokeError::Before(error)) => StepOutcome::Refused(error),
351 Err(InvokeError::Unconfirmed {
352 detail,
353 observed_destination,
354 publication,
355 }) => StepOutcome::Unconfirmed {
356 detail,
357 observed_destination: observed_destination.map(|value| *value),
358 recovery: None,
359 publication,
360 },
361 }
362}
363
364pub fn verify(receipt: &StepReceipt, token: &CancelToken) -> Result<VerifiedOutcome, FsFailure> {
365 let endpoint = endpoint(&receipt.operation.intent)?;
366 let mut request = request(&receipt.operation, "verify");
367 request["observed_destination"] = json!(match &receipt.outcome {
368 StepOutcome::Committed {
369 destination_after, ..
370 } => destination_after.as_ref(),
371 StepOutcome::Unconfirmed {
372 observed_destination,
373 ..
374 } => observed_destination.as_ref(),
375 _ => None,
376 });
377 request["publication"] = json!(match &receipt.outcome {
378 StepOutcome::Committed { publication, .. }
379 | StepOutcome::Unconfirmed { publication, .. } => publication.as_ref(),
380 _ => None,
381 });
382 invoke(endpoint, request, None, false, token).map_err(read_error)
383}