1use std::sync::Arc;
2
3use a3s_box_core::{
4 FileOp, FileRequest, FileResponse, FilesystemEntry, FilesystemOp, FilesystemRequest,
5 FilesystemResponse,
6};
7use base64::engine::general_purpose::STANDARD;
8use base64::Engine;
9
10use super::SandboxInner;
11use crate::{ClientError, Result};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct WriteInfo {
16 pub path: String,
17 pub size: u64,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct FilesystemOptions {
23 pub user: Option<String>,
24}
25
26impl FilesystemOptions {
27 pub fn user(mut self, user: impl Into<String>) -> Self {
28 self.user = Some(user.into());
29 self
30 }
31}
32
33#[derive(Clone)]
35pub struct Filesystem {
36 pub(crate) inner: Arc<SandboxInner>,
37}
38
39impl std::fmt::Debug for Filesystem {
40 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 formatter
42 .debug_struct("Filesystem")
43 .field("sandbox_id", &self.inner.execution_id)
44 .finish()
45 }
46}
47
48impl Filesystem {
49 pub async fn write(
50 &self,
51 path: impl Into<String>,
52 data: impl AsRef<[u8]>,
53 ) -> Result<WriteInfo> {
54 self.write_with_options(path, data, FilesystemOptions::default())
55 .await
56 }
57
58 pub async fn write_with_options(
59 &self,
60 path: impl Into<String>,
61 data: impl AsRef<[u8]>,
62 options: FilesystemOptions,
63 ) -> Result<WriteInfo> {
64 let path = path.into();
65 let response = self
66 .transfer(FileRequest {
67 op: FileOp::Upload,
68 guest_path: path.clone(),
69 data: Some(STANDARD.encode(data.as_ref())),
70 user: options.user,
71 })
72 .await?;
73 require_file_success(response).map(|response| WriteInfo {
74 path,
75 size: response.size,
76 })
77 }
78
79 pub async fn read(&self, path: impl Into<String>) -> Result<Vec<u8>> {
80 self.read_with_options(path, FilesystemOptions::default())
81 .await
82 }
83
84 pub async fn read_with_options(
85 &self,
86 path: impl Into<String>,
87 options: FilesystemOptions,
88 ) -> Result<Vec<u8>> {
89 let response = self
90 .transfer(FileRequest {
91 op: FileOp::Download,
92 guest_path: path.into(),
93 data: None,
94 user: options.user,
95 })
96 .await?;
97 let response = require_file_success(response)?;
98 STANDARD
99 .decode(response.data.unwrap_or_default())
100 .map_err(|error| {
101 ClientError::Guest(format!("guest returned invalid file data: {error}"))
102 })
103 }
104
105 pub async fn read_text(&self, path: impl Into<String>) -> Result<String> {
106 String::from_utf8(self.read(path).await?)
107 .map_err(|error| ClientError::Guest(format!("guest file is not valid UTF-8: {error}")))
108 }
109
110 pub async fn stat(&self, path: impl Into<String>) -> Result<FilesystemEntry> {
111 self.stat_with_options(path, FilesystemOptions::default())
112 .await
113 }
114
115 pub async fn stat_with_options(
116 &self,
117 path: impl Into<String>,
118 options: FilesystemOptions,
119 ) -> Result<FilesystemEntry> {
120 let response = self
121 .filesystem(FilesystemRequest {
122 op: FilesystemOp::Stat,
123 path: path.into(),
124 destination: None,
125 depth: 0,
126 user: options.user,
127 })
128 .await?;
129 let mut response = require_filesystem_success(response)?;
130 response.entry.take().ok_or_else(|| {
131 ClientError::Guest("guest stat response did not include an entry".to_string())
132 })
133 }
134
135 pub async fn exists(&self, path: impl Into<String>) -> Result<bool> {
136 match self.stat(path).await {
137 Ok(_) => Ok(true),
138 Err(ClientError::Guest(message))
139 if message.to_ascii_lowercase().contains("not found") =>
140 {
141 Ok(false)
142 }
143 Err(error) => Err(error),
144 }
145 }
146
147 pub async fn list(&self, path: impl Into<String>, depth: u32) -> Result<Vec<FilesystemEntry>> {
148 self.list_with_options(path, depth, FilesystemOptions::default())
149 .await
150 }
151
152 pub async fn list_with_options(
153 &self,
154 path: impl Into<String>,
155 depth: u32,
156 options: FilesystemOptions,
157 ) -> Result<Vec<FilesystemEntry>> {
158 let response = self
159 .filesystem(FilesystemRequest {
160 op: FilesystemOp::ListDir,
161 path: path.into(),
162 destination: None,
163 depth,
164 user: options.user,
165 })
166 .await?;
167 Ok(require_filesystem_success(response)?.entries)
168 }
169
170 pub async fn make_dir(&self, path: impl Into<String>) -> Result<()> {
171 self.make_dir_with_options(path, FilesystemOptions::default())
172 .await
173 }
174
175 pub async fn make_dir_with_options(
176 &self,
177 path: impl Into<String>,
178 options: FilesystemOptions,
179 ) -> Result<()> {
180 self.mutate(FilesystemRequest {
181 op: FilesystemOp::MakeDir,
182 path: path.into(),
183 destination: None,
184 depth: 0,
185 user: options.user,
186 })
187 .await
188 }
189
190 pub async fn move_path(
191 &self,
192 source: impl Into<String>,
193 destination: impl Into<String>,
194 ) -> Result<()> {
195 self.move_path_with_options(source, destination, FilesystemOptions::default())
196 .await
197 }
198
199 pub async fn move_path_with_options(
200 &self,
201 source: impl Into<String>,
202 destination: impl Into<String>,
203 options: FilesystemOptions,
204 ) -> Result<()> {
205 self.mutate(FilesystemRequest {
206 op: FilesystemOp::Move,
207 path: source.into(),
208 destination: Some(destination.into()),
209 depth: 0,
210 user: options.user,
211 })
212 .await
213 }
214
215 pub async fn remove(&self, path: impl Into<String>) -> Result<()> {
216 self.remove_with_options(path, FilesystemOptions::default())
217 .await
218 }
219
220 pub async fn remove_with_options(
221 &self,
222 path: impl Into<String>,
223 options: FilesystemOptions,
224 ) -> Result<()> {
225 self.mutate(FilesystemRequest {
226 op: FilesystemOp::Remove,
227 path: path.into(),
228 destination: None,
229 depth: 0,
230 user: options.user,
231 })
232 .await
233 }
234
235 async fn mutate(&self, request: FilesystemRequest) -> Result<()> {
236 require_filesystem_success(self.filesystem(request).await?).map(|_| ())
237 }
238
239 async fn transfer(&self, request: FileRequest) -> Result<FileResponse> {
240 let (_, generation) = self.inner.active_execution()?;
241 #[cfg(unix)]
242 {
243 self.inner
244 .client
245 .transfer_execution_file(&self.inner.execution_id, generation, request)
246 .await
247 }
248 #[cfg(not(unix))]
249 {
250 let _ = (generation, request);
251 Err(ClientError::Execution(
252 a3s_box_core::ExecutionManagerError::Unavailable(
253 "local file sessions are not available on this host".to_string(),
254 ),
255 ))
256 }
257 }
258
259 async fn filesystem(&self, request: FilesystemRequest) -> Result<FilesystemResponse> {
260 let (_, generation) = self.inner.active_execution()?;
261 #[cfg(unix)]
262 {
263 self.inner
264 .client
265 .filesystem_execution(&self.inner.execution_id, generation, request)
266 .await
267 }
268 #[cfg(not(unix))]
269 {
270 let _ = (generation, request);
271 Err(ClientError::Execution(
272 a3s_box_core::ExecutionManagerError::Unavailable(
273 "local filesystem sessions are not available on this host".to_string(),
274 ),
275 ))
276 }
277 }
278}
279
280fn require_file_success(response: FileResponse) -> Result<FileResponse> {
281 if response.success {
282 Ok(response)
283 } else {
284 Err(ClientError::Guest(response.error.unwrap_or_else(|| {
285 "guest file operation failed".to_string()
286 })))
287 }
288}
289
290fn require_filesystem_success(response: FilesystemResponse) -> Result<FilesystemResponse> {
291 if response.success {
292 Ok(response)
293 } else {
294 Err(ClientError::Guest(response.error.unwrap_or_else(|| {
295 "guest filesystem operation failed".to_string()
296 })))
297 }
298}