1/*
  2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
  3 *
  4 * See the NOTICE file(s) distributed with this work for additional
  5 * information regarding copyright ownership.
  6 *
  7 * This program and the accompanying materials are made available under the
  8 * terms of the Apache License Version 2.0 which is available at
  9 * https://www.apache.org/licenses/LICENSE-2.0
 10 *
 11 * SPDX-License-Identifier: Apache-2.0
 12 */
 13
 14use std::{sync::Arc, time::Duration};
 15
 16use aide::{axum::routing, swagger::Swagger};
 17use axum::{
 18    Json,
 19    http::{self, Request},
 20};
 21use cda_comm_doip::DoipGatewaySetupError;
 22use cda_interfaces::{
 23    FunctionalDescriptionConfig, HashMap, SchemaProvider, UdsEcu,
 24    communication_control::CommunicationAccess, datatypes::ComponentsConfig, dlt_ctx,
 25    file_manager::FileManager, http_protection::registry::HttpRouteMatcher,
 26};
 27use cda_plugin_security::SecurityPluginLoader;
 28use dynamic_router::DynamicRouter;
 29pub use dynamic_router::{RouteGroupNotFound, RouteHandle};
 30pub use http::Method;
 31use opensovd_axum_extra::ExtractHost;
 32use sovd::apps::sovd2uds::bulk_data::runtimefiles::RuntimeUpdateRouteState;
 33use tokio::net::TcpListener;
 34#[cfg(unix)]
 35use tokio::net::UnixListener;
 36use tower::{Layer, ServiceExt as TowerServiceExt};
 37use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
 38
 39/// Public API surface re-exported from the crate-internal `sovd` module.
 40pub use crate::sovd::{
 41    SovdLockStateProvider, error::VendorErrorCode, locks::Locks,
 42    request_guard::install_http_restriction_guard, static_data::add_static_data_endpoint,
 43};
 44pub mod dynamic_router;
 45mod openapi;
 46pub(crate) mod sovd;
 47
 48// Consts for HTTP
 49pub const SWAGGER_UI_ROUTE: &str = "/swagger-ui";
 50pub const OPENAPI_JSON_ROUTE: &str = "/openapi.json";
 51#[derive(Clone)]
 52pub enum WebServerConfig {
 53    /// Bind to this host/port over TCP.
 54    Tcp { host: String, port: u16 },
 55    /// Bind to this Unix domain socket path instead of TCP.
 56    UnixSocket { path: String },
 57}
 58
 59/// Static configuration for vehicle SOVD routes.
 60pub struct VehicleConfig {
 61    pub flash_files_path: String,
 62    /// Default functional class used to resolve the flash services 0x34, 0x36 and 0x37.
 63    pub flash_functional_class_default: String,
 64    pub functional_group_config: FunctionalDescriptionConfig,
 65    pub components_config: ComponentsConfig,
 66}
 67
 68/// Runtime resources (handles, shared state) for vehicle SOVD routes.
 69pub struct VehicleResources<T, M> {
 70    pub ecu_uds: T,
 71    pub file_managers: HashMap<String, M>,
 72    pub locks: Arc<Locks>,
 73    /// Access-only view used to admit diagnostic communication activities.
 74    pub communication_access: Arc<dyn CommunicationAccess>,
 75}
 76
[docs] 77/// [[ dimpl~sovd-api-http-server, Starts HTTP Server (TCP or Unix domain socket) ]]
 78///
 79/// Launches the http(s) webserver with deferred initialization
 80///
 81/// The server starts immediately with static endpoints. SOVD routes and other functionality
 82/// can be added later by calling methods on the returned `DynamicRouter`.
 83///
 84/// # Errors
 85/// Will return `Err` in case that the webserver couldn't be launched.
 86/// This can be caused due to invalid config, ports or addresses already being in use.
 87///
 88#[tracing::instrument(skip(config, shutdown_signal))]
 89pub async fn launch_webserver<F>(
 90    config: WebServerConfig,
 91    shutdown_signal: F,
 92) -> Result<(DynamicRouter, tokio::task::JoinHandle<()>), DoipGatewaySetupError>
 93where
 94    F: Future<Output = ()> + Clone + Send + 'static,
 95{
 96    let dynamic_router = DynamicRouter::new();
 97    let dynamic_router_for_service = dynamic_router.clone();
 98    let service = tower::service_fn(move |request: Request<axum::body::Body>| {
 99        let dr = dynamic_router_for_service.clone();
100        async move {
101            let router = dr.get_router().await;
102            TowerServiceExt::oneshot(router, request).await
103        }
104    });
105
106    let middleware = tower::util::MapRequestLayer::new(rewrite_request_uri);
107    let trim_trailing_slash_middleware = NormalizePathLayer::trim_trailing_slash();
108    let service_with_middleware = middleware.layer(trim_trailing_slash_middleware.layer(service));
109
110    let serve_future: std::pin::Pin<Box<dyn Future<Output = ()> + Send>> = match config {
111        WebServerConfig::UnixSocket { path: socket_path } => {
112            #[cfg(unix)]
113            {
114                remove_stale_unix_socket(&socket_path)?;
115                let listener = UnixListener::bind(&socket_path).map_err(|e| {
116                    DoipGatewaySetupError::ServerError(format!(
117                        "Failed to bind to unix socket {socket_path}: {e}"
118                    ))
119                })?;
120                tracing::info!("SOVD HTTP server listening on unix socket {socket_path}");
121                Box::pin(async move {
122                    let _ =
123                        axum::serve(listener, tower::make::Shared::new(service_with_middleware))
124                            .with_graceful_shutdown(shutdown_signal)
125                            .await;
126                })
127            }
128            #[cfg(not(unix))]
129            {
130                return Err(DoipGatewaySetupError::ServerError(format!(
131                    "Unix domain sockets are not supported on this platform (requested path: \
132                     {socket_path})"
133                )));
134            }
135        }
136        WebServerConfig::Tcp { host, port } => {
137            let listen_address = format!("{host}:{port}");
138            let listener = TcpListener::bind(&listen_address).await.map_err(|e| {
139                DoipGatewaySetupError::ServerError(format!(
140                    "Failed to bind to {listen_address}: {e}"
141                ))
142            })?;
143            tracing::info!("SOVD HTTP server listening on {listen_address}");
144            Box::pin(async move {
145                let _ = axum::serve(listener, tower::make::Shared::new(service_with_middleware))
146                    .with_graceful_shutdown(shutdown_signal)
147                    .await;
148            })
149        }
150    };
151
152    let webserver_task = cda_interfaces::spawn_named!("webserver", serve_future);
153
154    Ok((dynamic_router, webserver_task))
155}
156
157/// Removes a pre-existing file at `socket_path`, if any, so that binding a
158/// fresh `UnixListener` there doesn't fail with `AddrInUse` because of a
159/// socket file left behind by a previous unclean shutdown.
160#[cfg(unix)]
161fn remove_stale_unix_socket(socket_path: &str) -> Result<(), DoipGatewaySetupError> {
162    match std::fs::remove_file(socket_path) {
163        Ok(()) => {
164            tracing::debug!("Removed stale unix socket file at {socket_path}");
165            Ok(())
166        }
167        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
168        Err(e) => Err(DoipGatewaySetupError::ServerError(format!(
169            "Failed to remove stale unix socket file at {socket_path}: {e}"
170        ))),
171    }
172}
173
174/// Add vehicle routes to the dynamic router
175///
176/// This function should be called after the database is loaded to add all vehicle routes
177///
178/// # Errors
179/// Returns `Err` if routes cannot be added to the dynamic router.
180#[allow(
181    clippy::implicit_hasher,
182    reason = "Type alias doesn't allow specifying hasher"
183)]
184#[tracing::instrument(
185    skip(dynamic_router, config, resources),
186    fields(
187        flash_files_path = %config.flash_files_path
188    )
189)]
190pub async fn add_vehicle_routes<T, M, S>(
191    dynamic_router: &DynamicRouter,
192    config: VehicleConfig,
193    resources: VehicleResources<T, M>,
194) -> Result<RouteHandle, DoipGatewaySetupError>
195where
196    T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
197    M: FileManager + Send + Sync + 'static,
198    S: SecurityPluginLoader,
199{
200    let vehicle_router = build_vehicle_routes::<T, M, S>(config, resources).await;
201
202    let handle = dynamic_router.add_routes(vehicle_router).await;
203
204    tracing::info!("Vehicle routes added to webserver");
205    Ok(handle)
206}
207
208#[allow(
209    clippy::implicit_hasher,
210    reason = "Type alias doesn't allow specifying hasher"
211)]
212pub async fn build_vehicle_routes<T, M, S>(
213    config: VehicleConfig,
214    resources: VehicleResources<T, M>,
215) -> aide::axum::ApiRouter
216where
217    T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
218    M: FileManager + Send + Sync + 'static,
219    S: SecurityPluginLoader,
220{
221    sovd::route::<T, M, S>(
222        config.functional_group_config,
223        config.components_config,
224        &resources.ecu_uds,
225        sovd::FlashConfig {
226            files_path: config.flash_files_path,
227            functional_class_default: config.flash_functional_class_default,
228        },
229        resources.file_managers,
230        resources.locks,
231        resources.communication_access,
232    )
233    .await
234}
235
236/// Mounts the runtime-update HTTP routes onto the dynamic router and returns a handle to them.
237///
238/// Adds the runtime-file update endpoints to the router.
239pub async fn add_runtime_update_routes<S, P, L>(
240    dynamic_router: &DynamicRouter,
241    plugin: Arc<P>,
242    lock_state: Arc<L>,
243    upload_limit: usize,
244    retry_after: Duration,
245) -> RouteHandle
246where
247    S: SecurityPluginLoader,
248    P: cda_interfaces::runtime_update_api::RuntimeFilesUpdatePlugin,
249    L: cda_interfaces::runtime_update_api::LockStateProvider,
250{
251    let route_state = RuntimeUpdateRouteState {
252        plugin,
253        vehicle_lock_states: lock_state,
254        retry_after,
255    };
256    let bulk_data_router = sovd::apps::sovd2uds::bulk_data::runtimefiles::routes::<S, P, L>(
257        route_state.clone(),
258        upload_limit,
259    );
260    let operations_router =
261        sovd::apps::sovd2uds::operations::runtimefilesupdate::routes::<S, P, L>(route_state);
262    let router = bulk_data_router.merge(operations_router);
263    let handle = dynamic_router.add_routes(router.into()).await;
264    tracing::info!("Runtime update routes added to webserver");
265    handle
266}
267
268/// Routes that remain available while an update execution blocks other HTTP requests.
269#[must_use]
270pub fn routes_accessible_during_update() -> Vec<HttpRouteMatcher> {
271    sovd::apps::sovd2uds::operations::runtimefilesupdate::routes_accessible_during_update()
272}
273
274/// `OpenAPI` spec regenerates on every recomposition, reflecting current routes.
275///
276/// The server URL embedded in `openapi.json` is derived dynamically from each
277/// request's `Host` header (with `X-Forwarded-Host` / `Forwarded` taking
278/// precedence for reverse-proxy deployments), so the Swagger-UI always reflects
279/// the address the client actually used to reach CDA.
280pub async fn add_openapi_routes(dynamic_router: &DynamicRouter) {
281    let dr = dynamic_router.clone();
282    // Created once: aide leaks the Swagger UI HTML (~1.6 MB) for every route it creates,
283    // while the finalizer runs on every recomposition (e.g. each runtime database reload).
284    let swagger_route: axum::routing::MethodRouter =
285        Swagger::new(OPENAPI_JSON_ROUTE).axum_route().into();
286    dynamic_router
287        .add_finalizer(Arc::new(move |router: axum::Router| -> axum::Router {
288            let dr = dr.clone();
289            let swagger_route = swagger_route.clone();
290            let openapi_route: axum::routing::MethodRouter =
291                routing::get(move |ExtractHost(host): ExtractHost| {
292                    let dr = dr.clone();
293                    async move {
294                        let mut api = (*dr.get_openapi().await).clone();
295                        let server_url = format!("http://{host}");
296                        let _ = openapi::api_docs(
297                            aide::transform::TransformOpenApi::new(&mut api),
298                            server_url,
299                        );
300                        Json(api)
301                    }
302                })
303                .into();
304            router
305                .route(SWAGGER_UI_ROUTE, swagger_route)
306                .route(OPENAPI_JSON_ROUTE, openapi_route)
307        }))
308        .await;
309}
310
311fn rewrite_request_uri<B>(mut req: Request<B>) -> Request<B> {
312    let uri = req.uri();
313    let decoded_path = percent_encoding::percent_decode_str(uri.path())
314        .decode_utf8()
315        .unwrap_or_else(|_| uri.path().into());
316    let normalized_path = decoded_path.to_lowercase();
317    let normalized_path_and_query = uri.query().map_or_else(
318        || normalized_path.clone(),
319        |query| format!("{normalized_path}?{query}"),
320    );
321
322    let new_uri = match normalized_path_and_query.parse() {
323        Ok(uri) => uri,
324        Err(e) => {
325            tracing::warn!(error = %e, "Failed to parse URI, using original");
326            uri.clone()
327        }
328    };
329    *req.uri_mut() = new_uri;
330    req
331}
332
333fn create_trace_layer<S>(route: axum::Router<S>) -> axum::Router<S>
334where
335    S: Clone + Send + Sync + 'static,
336{
337    route.layer(
338        TraceLayer::new_for_http()
339            .make_span_with(|request: &axum::http::Request<_>| {
340                tracing::info_span!(
341                        "request",
342                    method = ?request.method(),
343                        path = request.uri().to_string(),
344                        status_code = tracing::field::Empty,
345                        latency = tracing::field::Empty,
346                        error = tracing::field::Empty,
347                        dlt_context = dlt_ctx!("SOVD"),
348                )
349            })
350            .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
351                tracing::debug!(
352                    method = %request.method(),
353                    path = %request.uri(),
354                    "Request received"
355                );
356            })
357            .on_response(
358                |response: &axum::http::Response<_>,
359                 latency: std::time::Duration,
360                 span: &tracing::Span| {
361                    span.record("status_code", response.status().as_u16());
362                    span.record("latency", format!("{latency:?}"));
363                },
364            )
365            .on_failure(
366                |error: tower_http::classify::ServerErrorsFailureClass,
367                 latency: std::time::Duration,
368                 span: &tracing::Span| {
369                    span.record("latency", format!("{latency:?}"));
370                    if let tower_http::classify::ServerErrorsFailureClass::StatusCode(status) =
371                        error
372                    {
373                        span.record("status_code", status.as_u16());
374                        if status == http::StatusCode::BAD_GATEWAY {
375                            return; // Ignore 502 errors
376                        }
377                    }
378                    span.record("error", error.to_string());
379                    tracing::error!("HTTP request failed");
380                },
381            ),
382    )
383}
384
385#[cfg(test)]
386mod tests {
387    use axum::{body::Body, http::Request};
388    use tower::ServiceExt;
389
390    use super::{OPENAPI_JSON_ROUTE, add_openapi_routes, dynamic_router::DynamicRouter};
391
392    #[tokio::test]
393    async fn openapi_route_is_available_without_vehicle_routes() {
394        let dynamic_router = DynamicRouter::new();
395        add_openapi_routes(&dynamic_router).await;
396
397        let response = dynamic_router
398            .get_router()
399            .await
400            .oneshot(
401                Request::builder()
402                    .uri(OPENAPI_JSON_ROUTE)
403                    .header("host", "127.0.0.1:20002")
404                    .body(Body::empty())
405                    .unwrap(),
406            )
407            .await
408            .unwrap();
409
410        assert_eq!(response.status(), axum::http::StatusCode::OK);
411    }
412}
413
414#[cfg(test)]
415pub(crate) mod test_utils {
416    use serde::de::DeserializeOwned;
417
418    pub(crate) async fn axum_response_into<T: DeserializeOwned>(
419        response: axum::response::Response,
420    ) -> Result<T, serde_json::Error> {
421        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
422            .await
423            .unwrap();
424        serde_json::from_slice::<T>(body.as_ref())
425    }
426}
427
428#[cfg(test)]
429mod uri_rewrite_tests {
430    use axum::{body::Body, extract::Query};
431    use sovd_interfaces::components::ecu::operations::{OperationDeleteQuery, OperationQuery};
432    use tower::ServiceExt;
433
434    use super::*;
435
436    async fn rewrite_uri(uri: &str) -> http::Uri {
437        let service = tower::service_fn(|request: Request<Body>| async move {
438            Ok::<_, std::convert::Infallible>(request.uri().clone())
439        });
440        let service = tower::util::MapRequestLayer::new(rewrite_request_uri).layer(service);
441        let request = Request::builder()
442            .uri(uri)
443            .body(Body::empty())
444            .expect("failed to build request");
445
446        service
447            .oneshot(request)
448            .await
449            .expect("URI rewrite service failed")
450    }
451
452    #[tokio::test]
453    async fn rewrite_request_uri_normalizes_only_path() {
454        let uri =
455            rewrite_uri("/Vehicle/%45CU?x-sovd2uds-suppressService=true&caseSensitive=AbC%26X%3DY")
456                .await;
457
458        assert_eq!(uri.path(), "/vehicle/ecu");
459        assert_eq!(
460            uri.query(),
461            Some("x-sovd2uds-suppressService=true&caseSensitive=AbC%26X%3DY")
462        );
463    }
464
465    #[tokio::test]
466    async fn rewrite_request_uri_preserves_operation_queries() {
467        let uri = rewrite_uri(concat!(
468            "/Vehicle/v15/components/ECU/operations/Routine/executions/id",
469            "?x-sovd2uds-suppressService=true"
470        ))
471        .await;
472        let Query(query) = Query::<OperationQuery>::try_from_uri(&uri)
473            .expect("failed to deserialize operation query");
474        let Query(delete_query) = Query::<OperationDeleteQuery>::try_from_uri(&uri)
475            .expect("failed to deserialize operation delete query");
476
477        assert!(query.suppress_service);
478        assert!(delete_query.suppress_service);
479    }
480}
481
482#[cfg(all(test, unix))]
483mod webserver_bind_tests {
484    use std::time::Duration;
485
486    use futures::FutureExt;
487
488    use super::*;
489
490    /// Sends a GET request over a Unix domain socket using `reqwest` (the
491    /// same HTTP client used elsewhere in this crate and in the integration
492    /// tests) and returns the response status code. Used instead of parsing
493    /// raw bytes off a `UnixStream`, so the tests exercise a spec-compliant
494    /// HTTP client the same way a real consumer of the Unix socket transport
495    /// would.
496    async fn get_over_unix_socket(socket_path: &str, path: &str) -> http::StatusCode {
497        let client = reqwest::Client::builder()
498            .unix_socket(socket_path)
499            .build()
500            .expect("failed to build unix socket client");
501
502        let response = client
503            .get(format!("http://localhost{path}"))
504            .send()
505            .await
506            .expect("failed to send request over unix socket");
507        http::StatusCode::from_u16(response.status().as_u16())
508            .expect("response returned an invalid status code")
509    }
510
511    fn shutdown_channel() -> (
512        tokio::sync::broadcast::Sender<()>,
513        impl Future<Output = ()> + Clone + Send + 'static,
514    ) {
515        let (tx, mut rx) = tokio::sync::broadcast::channel::<()>(1);
516        let signal = async move {
517            rx.recv().await.ok();
518        }
519        .shared();
520        (tx, signal)
521    }
522
523    /// Creates a temp directory and a `WebServerConfig::UnixSocket` pointing
524    /// at a socket file inside it. The returned `TempDir` must be kept alive
525    /// for as long as the socket path is used.
526    fn temp_socket_config() -> (tempfile::TempDir, String, WebServerConfig) {
527        let dir = tempfile::tempdir().expect("failed to create temp dir");
528        let socket_path = dir.path().join("cda.sock").to_string_lossy().to_string();
529        let config = WebServerConfig::UnixSocket {
530            path: socket_path.clone(),
531        };
532        (dir, socket_path, config)
533    }
534
535    /// Asserts that `socket_path` is reachable (a request gets routed through
536    /// the dynamic router, even if it 404s because no routes are registered),
537    /// then signals shutdown and waits for `webserver_task` to finish.
538    async fn assert_reachable_then_shutdown(
539        socket_path: &str,
540        shutdown_tx: tokio::sync::broadcast::Sender<()>,
541        webserver_task: tokio::task::JoinHandle<()>,
542    ) {
543        // No routes are registered, so the dynamic router responds with 404 -
544        // that's fine, we only need to confirm the connection was accepted
545        // and routed.
546        let status = get_over_unix_socket(socket_path, "/").await;
547        assert_eq!(status, http::StatusCode::NOT_FOUND);
548
549        let _ = shutdown_tx.send(());
550        tokio::time::timeout(Duration::from_secs(5), webserver_task)
551            .await
552            .expect("webserver task didn't shut down in time")
553            .expect("webserver task panicked");
554    }
555
[docs]556    /// [[ test~sovd-api-http-server-unix-socket-bind, Binds the server to a unix domain socket and reaches it, test ]]
557    #[tokio::test]
558    async fn launch_webserver_binds_unix_socket() {
559        let (_dir, socket_path, config) = temp_socket_config();
560        let (shutdown_tx, shutdown_signal) = shutdown_channel();
561
562        let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
563            .await
564            .expect("failed to launch webserver on unix socket");
565
566        assert_reachable_then_shutdown(&socket_path, shutdown_tx, webserver_task).await;
567    }
568
[docs]569    /// [[ test~sovd-api-http-server-tcp-bind, Binds the server to a TCP host/port and reaches it, test ]]
570    #[tokio::test]
571    async fn launch_webserver_binds_tcp() {
572        let tcp_listener = TcpListener::bind("127.0.0.1:0")
573            .await
574            .expect("failed to reserve a tcp port");
575        let tcp_port = tcp_listener.local_addr().unwrap().port();
576        drop(tcp_listener);
577
578        let config = WebServerConfig::Tcp {
579            host: "127.0.0.1".to_owned(),
580            port: tcp_port,
581        };
582        let (shutdown_tx, shutdown_signal) = shutdown_channel();
583
584        let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
585            .await
586            .expect("failed to launch webserver on tcp");
587
588        let client = reqwest::Client::new();
589        let response = client
590            .get(format!("http://127.0.0.1:{tcp_port}/"))
591            .send()
592            .await
593            .expect("failed to send request over tcp");
594        assert_eq!(response.status().as_u16(), http::StatusCode::NOT_FOUND);
595
596        let _ = shutdown_tx.send(());
597        tokio::time::timeout(Duration::from_secs(5), webserver_task)
598            .await
599            .expect("webserver task didn't shut down in time")
600            .expect("webserver task panicked");
601    }
602
[docs]603    /// [[ test~sovd-api-http-server-unix-socket-stale-cleanup, Removes a stale unix socket file left by an unclean shutdown before binding, test ]]
604    #[tokio::test]
605    async fn launch_webserver_removes_stale_unix_socket_file() {
606        let (_dir, socket_path, config) = temp_socket_config();
607
608        // Simulate a leftover socket file from a previous unclean shutdown.
609        std::fs::write(&socket_path, b"stale").expect("failed to create stale socket file");
610
611        let (shutdown_tx, shutdown_signal) = shutdown_channel();
612
613        let (_dynamic_router, webserver_task) = launch_webserver(config, shutdown_signal)
614            .await
615            .expect("failed to launch webserver despite stale socket file");
616
617        assert_reachable_then_shutdown(&socket_path, shutdown_tx, webserver_task).await;
618    }
619
[docs]620    /// [[ test~sovd-api-http-server-unix-socket-stale-cleanup-failure, Fails cleanly when a stale unix socket path can't be removed, test ]]
621    #[tokio::test]
622    async fn launch_webserver_fails_if_stale_socket_cannot_be_removed() {
623        let (_dir, socket_path, config) = temp_socket_config();
624        // A directory can't be removed with `remove_file`, so binding must fail
625        // with a clear error instead of silently misbehaving.
626        std::fs::create_dir(&socket_path).expect("failed to create directory");
627
628        let (_shutdown_tx, shutdown_signal) = shutdown_channel();
629
630        let result = launch_webserver(config, shutdown_signal).await;
631        assert!(
632            result.is_err(),
633            "expected launch_webserver to fail when the stale socket path is a directory"
634        );
635    }
636}