Skip to main content

rustferry_ssh/
provider.rs

1use std::{collections::BTreeSet, io::Cursor};
2
3use rustferry_remote::{
4    ArtifactDownloadRequest, ArtifactDownloadResult, ArtifactListRequest, ArtifactManifest,
5    BuildProvider, CancellationToken, EventPage, EventRequest, HandshakeRequest, HandshakeResponse,
6    IosArtifactType, IosDeviceBuildRequest, JobHandle, ProviderCapabilities, ProviderCheck,
7    ProviderCheckStatus, ProviderDoctorReport, ProviderDoctorRequest, ProviderFeature,
8    ProviderFuture, RemoteBuildError, RemoteBuildResult, SigningMode, SourceMode,
9    WorkerStdioCodecError, WorkerStdioRequest, WorkerStdioRequestEnvelope, WorkerStdioResponse,
10    WorkerStdioResponseEnvelope, decode_worker_stdio_response, encode_worker_stdio_request,
11};
12
13use crate::{
14    config::{SshConfigError, SshEndpointConfig},
15    transport::{
16        MAX_SSH_RESPONSE_BYTES, ProcessSshRunner, SshRunner, SshTransportError,
17        build_ssh_invocation,
18    },
19};
20
21/// Stable provider identifier used by the SSH client and macOS worker.
22pub const SSH_PROVIDER_ID: &str = "ssh-macos";
23
24/// SSH worker control plane used before the dedicated snapshot data plane.
25#[derive(Debug)]
26pub struct SshBuildProvider<R = ProcessSshRunner> {
27    config: SshEndpointConfig,
28    runner: R,
29    generic_capabilities: ProviderCapabilities,
30}
31
32impl SshBuildProvider<ProcessSshRunner> {
33    /// Create a provider using the fixed OpenSSH process runner.
34    #[must_use]
35    pub fn with_process_runner(config: SshEndpointConfig) -> Self {
36        Self::new(config, ProcessSshRunner)
37    }
38}
39
40impl<R> SshBuildProvider<R> {
41    /// Create a provider around a runtime-neutral runner.
42    #[must_use]
43    pub fn new(config: SshEndpointConfig, runner: R) -> Self {
44        Self {
45            config,
46            runner,
47            generic_capabilities: ProviderCapabilities::default(),
48        }
49    }
50
51    /// Validated named endpoint used by this provider.
52    pub fn config(&self) -> &SshEndpointConfig {
53        &self.config
54    }
55}
56
57impl<R> SshBuildProvider<R>
58where
59    R: SshRunner,
60{
61    fn exchange(
62        &self,
63        request: WorkerStdioRequest,
64        cancellation: &CancellationToken,
65    ) -> RemoteBuildResult<WorkerStdioResponseEnvelope> {
66        cancellation.check()?;
67        let invocation = build_ssh_invocation(&self.config).map_err(map_config_error)?;
68        let mut encoded = Vec::new();
69        encode_worker_stdio_request(&mut encoded, &WorkerStdioRequestEnvelope::new(request))
70            .map_err(map_request_codec_error)?;
71        let response = self
72            .runner
73            .exchange(&invocation, &encoded, cancellation)
74            .map_err(map_transport_error)?;
75        cancellation.check()?;
76        decode_worker_stdio_response(&mut Cursor::new(response)).map_err(map_response_codec_error)
77    }
78}
79
80impl<R> BuildProvider for SshBuildProvider<R>
81where
82    R: SshRunner,
83{
84    fn id(&self) -> &str {
85        SSH_PROVIDER_ID
86    }
87
88    fn capabilities(&self) -> &ProviderCapabilities {
89        &self.generic_capabilities
90    }
91
92    fn handshake(
93        &self,
94        request: HandshakeRequest,
95        cancellation: CancellationToken,
96    ) -> ProviderFuture<'_, HandshakeResponse> {
97        let result = (|| {
98            cancellation.check()?;
99            request.validate()?;
100            let snapshot_capabilities = snapshot_session_capabilities();
101            for feature in &request.required_features {
102                snapshot_capabilities.require(SSH_PROVIDER_ID, feature.clone())?;
103            }
104            let envelope = self.exchange(
105                WorkerStdioRequest::Handshake(request.clone()),
106                &cancellation,
107            )?;
108            match envelope.response {
109                WorkerStdioResponse::Handshake(mut response) => {
110                    if response.provider != SSH_PROVIDER_ID {
111                        return Err(provider_failure(
112                            "worker_identity_mismatch",
113                            "SSH worker returned an unexpected provider identity",
114                            false,
115                        ));
116                    }
117                    response.capabilities = intersect_snapshot_capabilities(&response.capabilities);
118                    response.validate_for(&request)?;
119                    Ok(response)
120                }
121                WorkerStdioResponse::Error(error) => Err(RemoteBuildError::ProviderFailure {
122                    provider: SSH_PROVIDER_ID.to_owned(),
123                    code: error.code,
124                    message: error.message,
125                    retryable: error.retryable,
126                }),
127                WorkerStdioResponse::ProviderDoctor(_) => Err(provider_failure(
128                    "unexpected_worker_response",
129                    "SSH worker returned a response for another operation",
130                    false,
131                )),
132            }
133        })();
134        Box::pin(async move { result })
135    }
136
137    fn doctor(
138        &self,
139        request: ProviderDoctorRequest,
140        cancellation: CancellationToken,
141    ) -> ProviderFuture<'_, ProviderDoctorReport> {
142        let result = (|| {
143            cancellation.check()?;
144            let envelope = self.exchange(
145                WorkerStdioRequest::ProviderDoctor(request.clone()),
146                &cancellation,
147            )?;
148            match envelope.response {
149                WorkerStdioResponse::ProviderDoctor(mut report) => {
150                    if report.provider != SSH_PROVIDER_ID {
151                        return Err(provider_failure(
152                            "worker_identity_mismatch",
153                            "SSH worker returned an unexpected provider identity",
154                            false,
155                        ));
156                    }
157                    report.capabilities = intersect_snapshot_capabilities(&report.capabilities);
158                    let complete = snapshot_required_features()
159                        .iter()
160                        .all(|feature| report.capabilities.supports(feature))
161                        && report.capabilities.retention_seconds == Some(0);
162                    if !complete {
163                        report.checks.push(ProviderCheck {
164                            code: "ssh.snapshot.unsupported".to_owned(),
165                            status: ProviderCheckStatus::Error,
166                            message: "SSH worker does not implement the complete unsigned snapshot session"
167                                .to_owned(),
168                            help: Some(
169                                "Upgrade cargo-ferry and ferry-worker-macos together, then rerun doctor"
170                                    .to_owned(),
171                            ),
172                        });
173                    }
174                    report.ready = report.ready
175                        && complete
176                        && report
177                            .checks
178                            .iter()
179                            .all(|check| check.status != ProviderCheckStatus::Error);
180                    Ok(report)
181                }
182                WorkerStdioResponse::Error(error) => Err(RemoteBuildError::ProviderFailure {
183                    provider: SSH_PROVIDER_ID.to_owned(),
184                    code: error.code,
185                    message: error.message,
186                    retryable: error.retryable,
187                }),
188                WorkerStdioResponse::Handshake(_) => Err(provider_failure(
189                    "unexpected_worker_response",
190                    "SSH worker returned a response for another operation",
191                    false,
192                )),
193            }
194        })();
195        Box::pin(async move { result })
196    }
197
198    fn submit(
199        &self,
200        _request: IosDeviceBuildRequest,
201        cancellation: CancellationToken,
202    ) -> ProviderFuture<'_, JobHandle> {
203        unsupported(
204            &cancellation,
205            ProviderFeature::IosDeviceBuild,
206            SSH_PROVIDER_ID,
207        )
208    }
209
210    fn events(
211        &self,
212        _request: EventRequest,
213        cancellation: CancellationToken,
214    ) -> ProviderFuture<'_, EventPage> {
215        unsupported(&cancellation, ProviderFeature::LiveEvents, SSH_PROVIDER_ID)
216    }
217
218    fn list_artifacts(
219        &self,
220        _request: ArtifactListRequest,
221        cancellation: CancellationToken,
222    ) -> ProviderFuture<'_, Vec<ArtifactManifest>> {
223        unsupported(
224            &cancellation,
225            ProviderFeature::ArtifactListing,
226            SSH_PROVIDER_ID,
227        )
228    }
229
230    fn download_artifact(
231        &self,
232        _request: ArtifactDownloadRequest,
233        cancellation: CancellationToken,
234    ) -> ProviderFuture<'_, ArtifactDownloadResult> {
235        unsupported(
236            &cancellation,
237            ProviderFeature::ArtifactDownload,
238            SSH_PROVIDER_ID,
239        )
240    }
241}
242
243/// Features required for one complete unsigned snapshot session.
244#[must_use]
245pub fn snapshot_required_features() -> Vec<ProviderFeature> {
246    vec![
247        ProviderFeature::SourceMode(SourceMode::Snapshot),
248        ProviderFeature::IosDeviceBuild,
249        ProviderFeature::SigningMode(SigningMode::UnsignedCompileOnly),
250        ProviderFeature::LiveEvents,
251        ProviderFeature::Cancellation,
252        ProviderFeature::ArtifactType(IosArtifactType::Xcarchive),
253        ProviderFeature::ArtifactDownload,
254        ProviderFeature::Cleanup,
255    ]
256}
257
258fn snapshot_session_capabilities() -> ProviderCapabilities {
259    ProviderCapabilities {
260        source_modes: BTreeSet::from([SourceMode::Snapshot]),
261        ios_device_build: true,
262        ios_simulator_build: false,
263        signing_modes: BTreeSet::from([SigningMode::UnsignedCompileOnly]),
264        personal_team: false,
265        live_events: true,
266        live_logs: false,
267        cancellation: true,
268        artifact_types: BTreeSet::from([IosArtifactType::Xcarchive]),
269        cache: false,
270        max_source_bytes: Some(640 * 1024 * 1024),
271        retention_seconds: Some(0),
272        artifact_listing: false,
273        artifact_download: true,
274        cleanup: true,
275        physical_device_access: false,
276    }
277}
278
279fn intersect_snapshot_capabilities(worker: &ProviderCapabilities) -> ProviderCapabilities {
280    let local = snapshot_session_capabilities();
281    ProviderCapabilities {
282        source_modes: local
283            .source_modes
284            .intersection(&worker.source_modes)
285            .copied()
286            .collect(),
287        ios_device_build: local.ios_device_build && worker.ios_device_build,
288        ios_simulator_build: false,
289        signing_modes: local
290            .signing_modes
291            .intersection(&worker.signing_modes)
292            .copied()
293            .collect(),
294        personal_team: false,
295        live_events: local.live_events && worker.live_events,
296        live_logs: false,
297        cancellation: local.cancellation && worker.cancellation,
298        artifact_types: local
299            .artifact_types
300            .intersection(&worker.artifact_types)
301            .copied()
302            .collect(),
303        cache: false,
304        max_source_bytes: Some(
305            worker
306                .max_source_bytes
307                .unwrap_or(u64::MAX)
308                .min(local.max_source_bytes.expect("local source limit")),
309        ),
310        retention_seconds: (worker.retention_seconds == Some(0)).then_some(0),
311        artifact_listing: false,
312        artifact_download: local.artifact_download && worker.artifact_download,
313        cleanup: local.cleanup && worker.cleanup,
314        physical_device_access: false,
315    }
316}
317
318fn unsupported<T>(
319    cancellation: &CancellationToken,
320    feature: ProviderFeature,
321    provider: &'static str,
322) -> ProviderFuture<'static, T>
323where
324    T: Send + 'static,
325{
326    let result = cancellation.check().and_then(|()| {
327        Err(RemoteBuildError::UnsupportedCapability {
328            provider: provider.to_owned(),
329            feature,
330        })
331    });
332    Box::pin(async move { result })
333}
334
335fn map_config_error(_error: SshConfigError) -> RemoteBuildError {
336    provider_failure(
337        "ssh_configuration_invalid",
338        "SSH endpoint trust or identity configuration is no longer valid",
339        false,
340    )
341}
342
343fn map_transport_error(error: SshTransportError) -> RemoteBuildError {
344    match error {
345        SshTransportError::Cancelled => RemoteBuildError::Cancelled,
346        SshTransportError::ResponseTooLarge { maximum } => RemoteBuildError::EventTooLarge {
347            bytes: maximum.saturating_add(1),
348            maximum,
349        },
350        SshTransportError::RequestTooLarge { .. } => RemoteBuildError::Serialization {
351            message: "worker request exceeds the SSH transport limit".to_owned(),
352        },
353        SshTransportError::SpawnFailed => provider_failure(
354            "ssh_client_unavailable",
355            "OpenSSH client could not be started",
356            false,
357        ),
358        SshTransportError::IoFailed => provider_failure(
359            "ssh_transport_io_failed",
360            "OpenSSH protocol pipe failed",
361            true,
362        ),
363        SshTransportError::TimedOut => provider_failure(
364            "ssh_transport_timeout",
365            "SSH worker operation timed out",
366            true,
367        ),
368        SshTransportError::IdentityFileChanged | SshTransportError::TrustSnapshotChanged => {
369            provider_failure(
370                "ssh_configuration_invalid",
371                "SSH endpoint trust or identity configuration is no longer valid",
372                false,
373            )
374        }
375        SshTransportError::ProcessFailed { .. } => provider_failure(
376            "ssh_process_failed",
377            "OpenSSH client exited unsuccessfully",
378            true,
379        ),
380    }
381}
382
383fn map_request_codec_error(error: WorkerStdioCodecError) -> RemoteBuildError {
384    RemoteBuildError::Serialization {
385        message: error.public_message().to_owned(),
386    }
387}
388
389fn map_response_codec_error(error: WorkerStdioCodecError) -> RemoteBuildError {
390    match error {
391        WorkerStdioCodecError::EmptyInput | WorkerStdioCodecError::TruncatedJson => {
392            RemoteBuildError::TruncatedEvent
393        }
394        WorkerStdioCodecError::ResponseTooLarge => RemoteBuildError::EventTooLarge {
395            bytes: MAX_SSH_RESPONSE_BYTES.saturating_add(1),
396            maximum: MAX_SSH_RESPONSE_BYTES,
397        },
398        WorkerStdioCodecError::IncompatibleProtocolVersion {
399            supported,
400            received,
401        } => RemoteBuildError::IncompatibleProtocolVersion {
402            supported,
403            received,
404        },
405        WorkerStdioCodecError::Io
406        | WorkerStdioCodecError::MalformedJson
407        | WorkerStdioCodecError::RequestTooLarge
408        | WorkerStdioCodecError::UnsupportedSchemaVersion { .. }
409        | WorkerStdioCodecError::InvalidRequest
410        | WorkerStdioCodecError::InvalidResponse => RemoteBuildError::MalformedEvent {
411            message: error.public_message().to_owned(),
412        },
413    }
414}
415
416fn provider_failure(
417    code: &'static str,
418    message: &'static str,
419    retryable: bool,
420) -> RemoteBuildError {
421    RemoteBuildError::ProviderFailure {
422        provider: SSH_PROVIDER_ID.to_owned(),
423        code: code.to_owned(),
424        message: message.to_owned(),
425        retryable,
426    }
427}