Skip to main content

sail/
client.rs

1//! The Sail client: the canonical async surface that owns configuration and
2//! transport, shared by every binding (Python, TypeScript, CLI).
3//!
4//! [`Client`] is a cheap-to-clone handle (`Arc` inside, like `reqwest::Client`):
5//! clone it freely to share the connection pools and config. Construct it with
6//! [`Client::from_env`] or [`Client::builder`].
7//!
8//! Every method is `async`. Synchronous callers (the PyO3 bridge with the GIL
9//! released, the CLI) drive these futures with
10//! [`crate::block_on`]; an async host awaits them directly.
11//!
12//! ```no_run
13//! # async fn run() -> Result<(), sail::error::SailError> {
14//! use sail::Client;
15//!
16//! // From the environment (SAIL_API_KEY):
17//! let client = Client::from_env()?;
18//! let page = client.list_sailboxes(&Default::default()).await?;
19//! println!("{} sailboxes", page.items.len());
20//!
21//! // Or build one explicitly:
22//! let client = Client::builder("sk_...").build()?;
23//! let app = client.find_app("my-app", /* mint_if_missing */ true).await?;
24//! # let _ = (client, app);
25//! # Ok(())
26//! # }
27//! ```
28
29use std::sync::Arc;
30use std::time::Duration;
31
32use crate::app::{self, App};
33use crate::config::Config;
34use crate::error::{GrpcCode, SailError};
35use crate::exec::{ExecOptions, ExecParams, ExecProcess, ExecResult, OutputStream};
36use crate::http::HttpCore;
37use crate::imagebuilder::ImageBuilder;
38use crate::sailbox::api::{SailboxApi, UpgradeResult};
39use crate::sailbox::fs::{DirEntry, EntryType};
40use crate::sailbox::object::Sailbox;
41use crate::sailbox::types::{
42    CreateSailboxRequest, ListSailboxesQuery, SailboxCheckpoint, SailboxHandle, SailboxInfo,
43    SailboxMetricsQuery, SailboxMetricsResponse, SailboxPage, SailboxSpendQuery,
44    SailboxSpendResponse, VolumeInfo,
45};
46use crate::worker::{FileReader, FileWriter, Listener, WorkerProxy, WriteOptions};
47
48/// A configured Sail client. Cheap to clone; shares transport across clones.
49#[derive(Clone)]
50pub struct Client {
51    inner: Arc<Inner>,
52}
53
54struct Inner {
55    config: Config,
56    /// Sailbox-API host: lifecycle, list/get, listeners, volume.
57    sailbox_http: HttpCore,
58    /// Central public-API host: app find, inference, voyages.
59    api_http: HttpCore,
60    /// Per-sailbox worker proxy: exec, files, listener reads. Its own `Arc` so
61    /// the streaming file/exec methods (which take `&Arc<Self>`) can share it.
62    worker: Arc<WorkerProxy>,
63    imagebuilder: ImageBuilder,
64}
65
66/// Builds a [`Client`] from explicit values, falling back to the default
67/// endpoints. Prefer [`Client::from_env`] for the common env-driven case.
68#[derive(Debug, Default, Clone)]
69pub struct ClientBuilder {
70    mode: Option<String>,
71    api_key: Option<String>,
72    api_url: Option<String>,
73    sailbox_api_url: Option<String>,
74    imagebuilder_url: Option<String>,
75    ingress_url: Option<String>,
76}
77
78impl ClientBuilder {
79    /// A builder with the given API key; unset endpoints use the Sail
80    /// defaults.
81    pub fn new(api_key: impl Into<String>) -> ClientBuilder {
82        ClientBuilder {
83            api_key: Some(api_key.into()),
84            ..ClientBuilder::default()
85        }
86    }
87
88    /// Select the named environment (`prod`/`dev`/`staging`/`local`), which
89    /// picks the endpoint defaults. Unset means prod.
90    #[doc(hidden)]
91    pub fn mode(mut self, mode: impl Into<String>) -> ClientBuilder {
92        self.mode = Some(mode.into());
93        self
94    }
95
96    /// Override the Sail API URL.
97    pub fn api_url(mut self, api_url: impl Into<String>) -> ClientBuilder {
98        self.api_url = Some(api_url.into());
99        self
100    }
101
102    /// Override the sailbox-API URL.
103    pub fn sailbox_api_url(mut self, url: impl Into<String>) -> ClientBuilder {
104        self.sailbox_api_url = Some(url.into());
105        self
106    }
107
108    /// Override the image-build endpoint (`host:port`).
109    pub fn imagebuilder_url(mut self, url: impl Into<String>) -> ClientBuilder {
110        self.imagebuilder_url = Some(url.into());
111        self
112    }
113
114    /// Override the listener ingress base URL (what `SAILBOX_INGRESS_URL`
115    /// sets from the environment), for custom or self-hosted sailbox stacks.
116    pub fn ingress_url(mut self, url: impl Into<String>) -> ClientBuilder {
117        self.ingress_url = Some(url.into());
118        self
119    }
120
121    /// Build the client, resolving any unset endpoint from the defaults.
122    pub fn build(self) -> Result<Client, SailError> {
123        let api_key = self.api_key.unwrap_or_default();
124        let config = Config::resolve(
125            self.mode.as_deref(),
126            api_key,
127            self.api_url,
128            self.sailbox_api_url,
129            self.imagebuilder_url,
130            self.ingress_url,
131        )?;
132        Client::from_config(config)
133    }
134}
135
136impl Client {
137    /// Start a [`ClientBuilder`].
138    pub fn builder(api_key: impl Into<String>) -> ClientBuilder {
139        ClientBuilder::new(api_key)
140    }
141
142    /// Build a client from the environment (`SAIL_API_KEY`, …).
143    pub fn from_env() -> Result<Client, SailError> {
144        Client::from_config(Config::from_env()?)
145    }
146
147    /// Build a client from an already-resolved [`Config`].
148    pub fn from_config(config: Config) -> Result<Client, SailError> {
149        let sailbox_http = HttpCore::new(&config.sailbox_api_url, &config.api_key)?;
150        let api_http = HttpCore::new(&config.api_url, &config.api_key)?;
151        let worker = Arc::new(WorkerProxy::new(&config.api_key)?);
152        let imagebuilder = ImageBuilder::new(&config.imagebuilder_url, &config.api_key)?;
153        Ok(Client {
154            inner: Arc::new(Inner {
155                config,
156                sailbox_http,
157                api_http,
158                worker,
159                imagebuilder,
160            }),
161        })
162    }
163
164    /// The resolved configuration.
165    pub fn config(&self) -> &Config {
166        &self.inner.config
167    }
168
169    /// The worker proxy for exec, file copy, and listener reads.
170    #[doc(hidden)]
171    pub fn worker(&self) -> Arc<WorkerProxy> {
172        Arc::clone(&self.inner.worker)
173    }
174
175    /// The imagebuilder dispatcher client.
176    #[doc(hidden)]
177    pub fn imagebuilder(&self) -> &ImageBuilder {
178        &self.inner.imagebuilder
179    }
180
181    /// The sailbox-API HTTP host (for binding-built requests).
182    #[doc(hidden)]
183    pub fn sailbox_http(&self) -> &HttpCore {
184        &self.inner.sailbox_http
185    }
186
187    /// The central public-API HTTP host (for binding-built requests).
188    #[doc(hidden)]
189    pub fn api_http(&self) -> &HttpCore {
190        &self.inner.api_http
191    }
192
193    fn sailbox_api(&self) -> SailboxApi<'_> {
194        SailboxApi::new(&self.inner.sailbox_http)
195    }
196
197    // --- sailbox lifecycle ---
198
199    /// Create a sailbox. `timeout` bounds each attempt of the synchronous
200    /// create (which can take minutes server-side); the call retries
201    /// with one idempotency key so the backend can dedupe rather than
202    /// duplicate, and gives up after roughly `max_attempts * timeout`.
203    /// An interrupted or re-invoked create is a new request and may leave a
204    /// prior box behind under the same name. 10 minutes is a good default;
205    /// `None` leaves each attempt unbounded. If the budget is exhausted the
206    /// box may still be coming up server-side; find or terminate it by
207    /// `name`.
208    pub async fn create_sailbox(
209        &self,
210        req: &CreateSailboxRequest,
211        timeout: Option<Duration>,
212    ) -> Result<Sailbox, SailError> {
213        let bind = |handle: SailboxHandle| Sailbox::bind(self.clone(), handle);
214        if !req.ssh {
215            return self.sailbox_api().create(req, timeout).await.map(bind);
216        }
217        // Validate the full request now, port-22 entries included: they are
218        // stripped below (their allowlist applies at the enable_ssh expose),
219        // so create's own validation never sees them, and an invalid entry
220        // must fail here rather than after the VM exists.
221        crate::sailbox::api::validate_ingress_ports(&req.ingress_ports)?;
222        // SSH setup is org-scoped: preflight the org CA (created on first use)
223        // so a CA outage fails before the VM exists.
224        self.org_ssh_ca_public_key().await?;
225        // Port 22 belongs to enable_ssh, which exposes it only after verifying
226        // the CA-only sshd owns it — never the create request — so a failed
227        // setup can't leave port 22 exposed. An explicit port-22 entry
228        // contributes just its allowlist, applied at that expose.
229        let mut req = req.clone();
230        let ssh_allowlist = req
231            .ingress_ports
232            .iter()
233            .find(|port| port.guest_port == 22)
234            .map(|port| port.allowlist.clone())
235            .unwrap_or_default();
236        req.ingress_ports.retain(|port| port.guest_port != 22);
237        let handle = self.sailbox_api().create(&req, timeout).await?;
238        let handle_id = handle.sailbox_id.clone();
239        // The VM is already up, so skip the readiness probe (wait: false).
240        if let Err(err) = self
241            .enable_ssh(
242                &handle_id,
243                &ssh_allowlist,
244                /* wait */ false,
245                Duration::ZERO,
246            )
247            .await
248        {
249            // The sailbox exists; surface its id so the caller can fetch it to
250            // retry enable_ssh or terminate it.
251            return Err(SailError::Creation {
252                message: format!(
253                    "sailbox {handle_id} was created, but SSH setup failed: {err}. Fetch it by \
254                     id to retry enable_ssh or terminate it."
255                ),
256                status: 0,
257                body: serde_json::Value::Null,
258            });
259        }
260        Ok(bind(handle))
261    }
262
263    /// Fetch a single sailbox.
264    #[doc(hidden)]
265    pub async fn get_sailbox(&self, sailbox_id: &str) -> Result<SailboxInfo, SailError> {
266        self.sailbox_api().get(sailbox_id).await
267    }
268
269    /// List sailboxes in the current org.
270    pub async fn list_sailboxes(
271        &self,
272        query: &ListSailboxesQuery,
273    ) -> Result<SailboxPage, SailError> {
274        self.sailbox_api().list(query).await
275    }
276
277    /// Estimate sailbox spend for the current organization over a time window.
278    pub async fn sailbox_spend(
279        &self,
280        query: &SailboxSpendQuery,
281    ) -> Result<SailboxSpendResponse, SailError> {
282        self.sailbox_api().spend(query).await
283    }
284
285    /// Fetch a sailbox's resource-usage time series.
286    pub async fn sailbox_metrics(
287        &self,
288        sailbox_id: &str,
289        query: &SailboxMetricsQuery,
290    ) -> Result<SailboxMetricsResponse, SailError> {
291        self.sailbox_api().metrics(sailbox_id, query).await
292    }
293
294    /// Terminate a sailbox (idempotent).
295    #[doc(hidden)]
296    pub async fn terminate_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
297        self.sailbox_api().terminate(sailbox_id).await
298    }
299
300    /// Pause a sailbox.
301    #[doc(hidden)]
302    pub async fn pause_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
303        self.sailbox_api().pause(sailbox_id).await
304    }
305
306    /// Sleep a sailbox.
307    #[doc(hidden)]
308    pub async fn sleep_sailbox(&self, sailbox_id: &str) -> Result<(), SailError> {
309        self.sailbox_api().sleep(sailbox_id).await
310    }
311
312    /// Resume a paused/sleeping sailbox.
313    #[doc(hidden)]
314    pub async fn resume_sailbox(&self, sailbox_id: &str) -> Result<SailboxHandle, SailError> {
315        self.sailbox_api().resume(sailbox_id).await
316    }
317
318    /// Checkpoint a running sailbox.
319    #[doc(hidden)]
320    pub async fn checkpoint_sailbox(
321        &self,
322        sailbox_id: &str,
323        name: Option<&str>,
324        ttl_seconds: Option<i64>,
325    ) -> Result<SailboxCheckpoint, SailError> {
326        self.sailbox_api()
327            .checkpoint(sailbox_id, name, ttl_seconds)
328            .await
329    }
330
331    /// Create a new sailbox from a checkpoint.
332    pub async fn create_from_checkpoint(
333        &self,
334        checkpoint_id: &str,
335        name: Option<&str>,
336        timeout: Option<Duration>,
337    ) -> Result<Sailbox, SailError> {
338        self.sailbox_api()
339            .from_checkpoint(checkpoint_id, name, timeout.map(|t| t.as_secs() as i64))
340            .await
341            .map(|handle| Sailbox::bind(self.clone(), handle))
342    }
343
344    /// Upgrade the in-guest agent (applies now if running, else at next wake).
345    #[doc(hidden)]
346    pub async fn upgrade_sailbox(&self, sailbox_id: &str) -> Result<UpgradeResult, SailError> {
347        self.sailbox_api().upgrade(sailbox_id).await
348    }
349
350    /// Expose a guest port at runtime; returns the add-listener response.
351    #[doc(hidden)]
352    pub async fn expose_listener(
353        &self,
354        sailbox_id: &str,
355        guest_port: u32,
356        protocol: crate::sailbox::types::IngressProtocol,
357        allowlist: &[String],
358    ) -> Result<Listener, SailError> {
359        let mut response = self
360            .sailbox_api()
361            .expose(sailbox_id, guest_port, protocol, allowlist)
362            .await?;
363        self.fill_listener_url(sailbox_id, &mut response);
364        Ok(response)
365    }
366
367    /// Remove a runtime ingress port.
368    #[doc(hidden)]
369    pub async fn unexpose_listener(
370        &self,
371        sailbox_id: &str,
372        guest_port: u32,
373    ) -> Result<(), SailError> {
374        self.sailbox_api().unexpose(sailbox_id, guest_port).await
375    }
376
377    /// List a sailbox's ingress listeners without resuming (waking) the box.
378    #[doc(hidden)]
379    pub async fn list_listeners(
380        &self,
381        sailbox_id: &str,
382    ) -> Result<Vec<crate::worker::Listener>, SailError> {
383        let mut listeners = self.sailbox_api().list_listeners(sailbox_id).await?;
384        for listener in &mut listeners {
385            self.fill_listener_url(sailbox_id, listener);
386        }
387        Ok(listeners)
388    }
389
390    /// Fetch one ingress listener by guest port without resuming (waking) the
391    /// box; a missing port is a [`SailError::NotFound`].
392    #[doc(hidden)]
393    pub async fn get_listener(
394        &self,
395        sailbox_id: &str,
396        guest_port: u32,
397    ) -> Result<crate::worker::Listener, SailError> {
398        let mut listener = self
399            .sailbox_api()
400            .get_listener(sailbox_id, guest_port)
401            .await?;
402        self.fill_listener_url(sailbox_id, &mut listener);
403        Ok(listener)
404    }
405
406    /// Fill an empty `public_url` on a non-TCP listener with the URL
407    /// synthesized from this client's ingress config (the server leaves
408    /// listener URLs empty in local/path mode).
409    fn fill_listener_url(&self, sailbox_id: &str, listener: &mut crate::worker::Listener) {
410        if listener.public_url.is_empty()
411            && listener.protocol != crate::sailbox::types::ListenerProtocol::Tcp
412        {
413            listener.public_url = crate::sailbox::listeners::synthesized_public_url(
414                self.config(),
415                sailbox_id,
416                listener.guest_port,
417            );
418        }
419    }
420
421    /// Ingress-identity headers for this sailbox.
422    #[doc(hidden)]
423    pub async fn ingress_auth_headers(
424        &self,
425        sailbox_id: &str,
426    ) -> Result<Vec<(String, String)>, SailError> {
427        self.sailbox_api().ingress_auth_headers(sailbox_id).await
428    }
429
430    /// The caller org's SSH CA public key (created on first use).
431    pub async fn org_ssh_ca_public_key(&self) -> Result<String, SailError> {
432        self.sailbox_api().org_ssh_ca_public_key().await
433    }
434
435    /// Sign `public_key` into a short-lived org-CA certificate (principal
436    /// `root`). `timeout` (seconds) bounds a single no-retry attempt; `None`
437    /// retries.
438    pub async fn issue_user_cert(
439        &self,
440        public_key: &str,
441        timeout: Option<Duration>,
442    ) -> Result<crate::sailbox::types::IssuedUserCert, SailError> {
443        self.sailbox_api()
444            .issue_user_cert(public_key, timeout.map(|t| t.as_secs_f64()))
445            .await
446    }
447
448    // --- NFS volumes ---
449
450    /// Look up (optionally minting) an NFS volume by name.
451    pub async fn get_volume(
452        &self,
453        name: &str,
454        mint_if_missing: bool,
455    ) -> Result<VolumeInfo, SailError> {
456        self.sailbox_api().get_volume(name, mint_if_missing).await
457    }
458
459    /// List NFS volumes in the current org.
460    pub async fn list_volumes(
461        &self,
462        max_objects: Option<i64>,
463    ) -> Result<Vec<VolumeInfo>, SailError> {
464        self.sailbox_api().list_volumes(max_objects).await
465    }
466
467    /// Delete a volume by id.
468    pub async fn delete_volume(
469        &self,
470        volume_id: &str,
471        allow_missing: bool,
472    ) -> Result<Option<VolumeInfo>, SailError> {
473        self.sailbox_api()
474            .delete_volume(volume_id, allow_missing)
475            .await
476    }
477
478    // --- apps (central API) ---
479
480    /// Find an app by name, optionally minting it.
481    pub async fn find_app(&self, name: &str, mint_if_missing: bool) -> Result<App, SailError> {
482        app::find_app(&self.inner.api_http, name, mint_if_missing).await
483    }
484
485    /// Every app the current org owns, newest first.
486    pub async fn list_apps(&self) -> Result<Vec<App>, SailError> {
487        app::list_apps(&self.inner.api_http).await
488    }
489
490    // --- exec and files (per-sailbox worker proxy) ---
491
492    /// Resolve a sailbox's current worker-proxy endpoint.
493    ///
494    /// `resume` wakes a paused/sleeping sailbox and returns its *current*
495    /// endpoint, which is the host worker's address and changes when the sailbox
496    /// migrates (e.g. after preemption). The GET sailbox API omits this routing
497    /// field, so resuming is the only way to learn it, and resolving it fresh per
498    /// call avoids ever dialing a stale worker.
499    #[doc(hidden)]
500    pub async fn exec_endpoint(&self, sailbox_id: &str) -> Result<String, SailError> {
501        let handle = self.resume_sailbox(sailbox_id).await?;
502        if handle.exec_endpoint.is_empty() {
503            return Err(SailError::Internal {
504                message: format!("sailbox {sailbox_id} resumed without an exec endpoint"),
505            });
506        }
507        Ok(handle.exec_endpoint)
508    }
509
510    /// Id-form of [`Sailbox::exec`](crate::Sailbox::exec), which documents the
511    /// contract. Spawns the output pump on the calling task's tokio runtime.
512    #[doc(hidden)]
513    pub async fn exec(
514        &self,
515        sailbox_id: &str,
516        argv: Vec<String>,
517        options: ExecOptions,
518    ) -> Result<ExecProcess, SailError> {
519        if argv.is_empty() {
520            return Err(SailError::InvalidArgument {
521                message: "command must be non-empty".to_string(),
522            });
523        }
524        if options.cwd.is_some() || options.background {
525            return Err(SailError::InvalidArgument {
526                message: "cwd and background require a shell command; use exec_shell".to_string(),
527            });
528        }
529        // Validate and encode the env before resolving the endpoint: it is
530        // purely local, so a malformed key must not first wake a paused sailbox.
531        let env = crate::exec::encode_env(&options.env)?;
532        let exec_endpoint = self.exec_endpoint(sailbox_id).await?;
533        let params = ExecParams {
534            sailbox_id: sailbox_id.to_string(),
535            exec_endpoint,
536            argv,
537            // The wire is whole seconds where 0 means "no limit", so a set
538            // sub-second timeout rounds up to 1s rather than collapsing to 0.
539            timeout_seconds: options
540                .timeout
541                .map_or(0, |d| d.as_secs_f64().ceil().max(1.0) as u32),
542            idempotency_key: options.idempotency_key,
543            // A pty always feeds keystrokes to the command, so it implies an
544            // open stdin regardless of the flag.
545            open_stdin: options.open_stdin || options.pty,
546            pty: options.pty,
547            term: options.term,
548            cols: options.cols,
549            rows: options.rows,
550            env,
551            retry_timeout: options.retry_timeout.as_secs_f64(),
552            forward_ports: options.forward_ports,
553            forward_browser: options.forward_browser,
554            extra_metadata: Vec::new(),
555        };
556        ExecProcess::start(self.worker(), params).await
557    }
558
559    /// Run a shell command in a sailbox via `/bin/sh -lc`, honoring the
560    /// `cwd`/`background` conveniences in [`ExecOptions`]. See [`Client::exec`]
561    /// for the argv form and the runtime notes.
562    #[doc(hidden)]
563    pub async fn exec_shell(
564        &self,
565        sailbox_id: &str,
566        command: &str,
567        mut options: ExecOptions,
568    ) -> Result<ExecProcess, SailError> {
569        let argv = crate::exec::shell_argv(command, &options)?;
570        // The conveniences are baked into the argv now; clear them so the argv
571        // path's guard does not re-reject them.
572        options.cwd = None;
573        options.background = false;
574        self.exec(sailbox_id, argv, options).await
575    }
576
577    /// Open a streaming read of a guest file. Resumes (wakes) the sailbox to
578    /// reach it; the returned [`FileReader`] yields chunks until end of file.
579    ///
580    /// # Runtime
581    ///
582    /// Spawns the read pump on the calling task's tokio runtime (see
583    /// [`crate::worker::WorkerProxy::read_file`]).
584    #[doc(hidden)]
585    pub async fn read_stream(
586        &self,
587        sailbox_id: &str,
588        remote_path: &str,
589    ) -> Result<FileReader, SailError> {
590        let endpoint = self.exec_endpoint(sailbox_id).await?;
591        Ok(self
592            .inner
593            .worker
594            .read_file(&endpoint, sailbox_id, remote_path))
595    }
596
597    /// Read a guest file into memory in one call (convenience over
598    /// [`Client::read_stream`], which streams a large file without
599    /// buffering it whole).
600    #[doc(hidden)]
601    pub async fn read_file(
602        &self,
603        sailbox_id: &str,
604        remote_path: &str,
605    ) -> Result<Vec<u8>, SailError> {
606        let reader = self.read_stream(sailbox_id, remote_path).await?;
607        let mut contents = Vec::new();
608        while let Some(chunk) = reader.next().await {
609            contents.extend_from_slice(&chunk?);
610        }
611        Ok(contents)
612    }
613
614    /// Open a streaming write to a guest file. Resumes (wakes) the sailbox to
615    /// reach it; feed the returned [`FileWriter`] with `write_chunk` and end with
616    /// `finish`, so a large source is never buffered whole.
617    ///
618    /// # Runtime
619    ///
620    /// Spawns the write RPC on the calling task's tokio runtime (see
621    /// [`crate::worker::WorkerProxy::write_file`]).
622    #[doc(hidden)]
623    pub async fn write_stream(
624        &self,
625        sailbox_id: &str,
626        remote_path: &str,
627        options: WriteOptions,
628    ) -> Result<FileWriter, SailError> {
629        let endpoint = self.exec_endpoint(sailbox_id).await?;
630        Ok(self.inner.worker.write_file(
631            &endpoint,
632            sailbox_id,
633            remote_path,
634            options.create_parents,
635            options.mode,
636        ))
637    }
638
639    /// Write `data` to a guest file in one call (convenience over
640    /// [`Client::write_stream`], which streams a large source without
641    /// buffering it whole).
642    #[doc(hidden)]
643    pub async fn write_file(
644        &self,
645        sailbox_id: &str,
646        remote_path: &str,
647        data: &[u8],
648        options: WriteOptions,
649    ) -> Result<(), SailError> {
650        let mut writer = self.write_stream(sailbox_id, remote_path, options).await?;
651        writer.write(data).await?;
652        writer.finish().await
653    }
654
655    // --- filesystem helpers ---
656    //
657    // These build a coreutils command, run it to completion, and inspect the
658    // result. They live in the core so the command construction and the `find`
659    // output parse are defined once, and every language binding consumes the
660    // structured results rather than re-parsing `find`'s output.
661
662    /// Run a command to completion and return its buffered result.
663    async fn run_argv(&self, sailbox_id: &str, argv: Vec<String>) -> Result<ExecResult, SailError> {
664        self.exec(sailbox_id, argv, ExecOptions::default())
665            .await?
666            .wait()
667            .await
668    }
669
670    /// Create a directory and any missing parents (like `mkdir -p`); a no-op if
671    /// it already exists.
672    #[doc(hidden)]
673    pub async fn make_dir(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
674        crate::sailbox::fs::require_path(path)?;
675        let result = self
676            .run_argv(
677                sailbox_id,
678                vec![
679                    "mkdir".to_string(),
680                    "-p".to_string(),
681                    "--".to_string(),
682                    path.to_string(),
683                ],
684            )
685            .await?;
686        fs_command_ok(&result, &format!("create directory {path}"))
687    }
688
689    /// Remove a file or directory tree (like `rm -rf`); a no-op if it is already
690    /// absent.
691    #[doc(hidden)]
692    pub async fn remove_path(&self, sailbox_id: &str, path: &str) -> Result<(), SailError> {
693        crate::sailbox::fs::require_path(path)?;
694        let result = self
695            .run_argv(
696                sailbox_id,
697                vec![
698                    "rm".to_string(),
699                    "-rf".to_string(),
700                    "--".to_string(),
701                    path.to_string(),
702                ],
703            )
704            .await?;
705        fs_command_ok(&result, &format!("remove {path}"))
706    }
707
708    /// Whether `path` exists in the guest. Follows symlinks (like `test -e`), so
709    /// a dangling symlink reports `false`.
710    #[doc(hidden)]
711    pub async fn path_exists(&self, sailbox_id: &str, path: &str) -> Result<bool, SailError> {
712        crate::sailbox::fs::require_path(path)?;
713        let result = self
714            .run_argv(
715                sailbox_id,
716                vec!["test".to_string(), "-e".to_string(), path.to_string()],
717            )
718            .await?;
719        // `test -e` answers with its exit code: 0 exists, 1 does not. Any other
720        // code (for example a signal-killed process) is a failed check, not an
721        // answer, so surface it rather than reading it as absent.
722        match result.exit_code {
723            0 => Ok(true),
724            1 => Ok(false),
725            _ => Err(fs_command_error(
726                &result,
727                &format!("check whether {path} exists"),
728            )),
729        }
730    }
731
732    /// List a directory's immediate entries (files and subdirectories, no
733    /// recursion). Requires GNU `find`, which the default Debian image ships. A
734    /// missing path errors, as does a path that exists but is not a directory.
735    #[doc(hidden)]
736    pub async fn list_dir(&self, sailbox_id: &str, path: &str) -> Result<Vec<DirEntry>, SailError> {
737        crate::sailbox::fs::require_path(path)?;
738        let process = self
739            .exec(
740                sailbox_id,
741                crate::sailbox::fs::list_dir_argv(path),
742                ExecOptions::default(),
743            )
744            .await?;
745        let result = process.wait().await?;
746        fs_command_ok(&result, &format!("list directory {path}"))?;
747        // Buffered stdout is a capped, drop-oldest tail, so parsing a truncated
748        // listing would silently drop entries.
749        if result.stdout_truncated {
750            return Err(SailError::Execution {
751                code: GrpcCode::FailedPrecondition,
752                detail: format!(
753                    "directory listing for {path} was truncated because it has \
754                     too many entries; list a smaller subtree"
755                ),
756            });
757        }
758        // The records are NUL-terminated, and only the raw buffered bytes keep
759        // NUL: the string-typed `ExecResult` replaces it, as does the persisted
760        // tail that `wait` falls back to when the live stream loses its ending.
761        // So parse the local raw bytes, and require that the stream delivered
762        // them all; when it did not, the local buffer may be missing entries.
763        if !result.stdout_complete {
764            return Err(SailError::Execution {
765                code: GrpcCode::FailedPrecondition,
766                detail: format!(
767                    "directory listing for {path} was interrupted before it \
768                     finished streaming; retry the call"
769                ),
770            });
771        }
772        let mut entries =
773            crate::sailbox::fs::parse_dir_entries(&process.buffered_output(OutputStream::Stdout))
774                .map_err(|detail| SailError::Execution {
775                code: GrpcCode::FailedPrecondition,
776                detail: format!("directory listing for {path} could not be used: {detail}"),
777            })?;
778        // `find` emits the start point itself as the first record, carrying the
779        // path's own type.
780        if entries.is_empty() {
781            return Err(SailError::Execution {
782                code: GrpcCode::FailedPrecondition,
783                detail: format!(
784                    "directory listing for {path} produced no records; \
785                     listing requires GNU find in the guest"
786                ),
787            });
788        }
789        let start = entries.remove(0);
790        if start.entry_type != EntryType::Directory {
791            return Err(SailError::Execution {
792                code: GrpcCode::FailedPrecondition,
793                detail: format!(
794                    "{path} is not a directory (it is a {})",
795                    start.entry_type.as_str()
796                ),
797            });
798        }
799        Ok(entries)
800    }
801}
802
803/// Fail on a non-zero exit from a filesystem helper command.
804fn fs_command_ok(result: &ExecResult, action: &str) -> Result<(), SailError> {
805    if result.exit_code != 0 {
806        return Err(fs_command_error(result, action));
807    }
808    Ok(())
809}
810
811/// The error for a failed filesystem helper command, folding the guest's stderr
812/// into the message.
813fn fs_command_error(result: &ExecResult, action: &str) -> SailError {
814    let stderr = result.stderr.trim();
815    let suffix = if stderr.is_empty() {
816        String::new()
817    } else {
818        format!(": {stderr}")
819    };
820    SailError::Execution {
821        code: GrpcCode::FailedPrecondition,
822        detail: format!(
823            "failed to {action} (exit code {}){suffix}",
824            result.exit_code
825        ),
826    }
827}