diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
index 8a4d423061..15792779a5 100644
--- a/.github/workflows/develop.yml
+++ b/.github/workflows/develop.yml
@@ -850,7 +850,10 @@ jobs:
- name: Unzip binaries
run: tar -xvf ./bins.tar.gz
- name: Start GreptimeDB standalone
+ env:
+ GREPTIMEDB_STANDALONE__STORAGE__COPY_ROOT: ${{ runner.temp }}/greptime-export-import-v2
run: |
+ mkdir -p "${GREPTIMEDB_STANDALONE__STORAGE__COPY_ROOT}"
./bins/greptime standalone start > greptimedb.log 2>&1 &
greptime_pid=$!
echo "Waiting for GreptimeDB..."
@@ -870,6 +873,7 @@ jobs:
- name: Run export/import v2 E2E tests
run: cargo test -p cli data::export_v2::tests --lib -- --ignored --test-threads=1
env:
+ TMPDIR: ${{ runner.temp }}/greptime-export-import-v2
GREPTIME_ADDR: 127.0.0.1:4000
GT_S3_BUCKET: greptime
GT_S3_ACCESS_KEY_ID: superpower_ci_user
diff --git a/Cargo.lock b/Cargo.lock
index d24b38e033..f82073177c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -125,6 +125,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+[[package]]
+name = "ambient-authority"
+version = "0.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -1603,6 +1609,36 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbc26382d871df4b7442e3df10a9402bf3cf5e55cbd66f12be38861425f0564"
+[[package]]
+name = "cap-primitives"
+version = "4.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0"
+dependencies = [
+ "ambient-authority",
+ "fs-set-times",
+ "io-extras",
+ "io-lifetimes 3.0.1",
+ "ipnet",
+ "maybe-owned",
+ "rustix 1.0.7",
+ "rustix-linux-procfs",
+ "windows-sys 0.61.2",
+ "winx",
+]
+
+[[package]]
+name = "cap-std"
+version = "4.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608"
+dependencies = [
+ "cap-primitives",
+ "io-extras",
+ "io-lifetimes 3.0.1",
+ "rustix 1.0.7",
+]
+
[[package]]
name = "cargo-manifest"
version = "0.19.1"
@@ -2113,6 +2149,7 @@ dependencies = [
"common-base",
"common-catalog",
"common-config",
+ "common-datasource",
"common-error",
"common-grpc",
"common-macro",
@@ -4424,6 +4461,7 @@ dependencies = [
"common-base",
"common-catalog",
"common-config",
+ "common-datasource",
"common-error",
"common-function",
"common-greptimedb-telemetry",
@@ -5371,6 +5409,7 @@ dependencies = [
"common-base",
"common-catalog",
"common-config",
+ "common-datasource",
"common-decimal",
"common-error",
"common-frontend",
@@ -5643,6 +5682,17 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "fs-set-times"
+version = "0.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
+dependencies = [
+ "io-lifetimes 2.0.4",
+ "rustix 1.0.7",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "fs2"
version = "0.4.3"
@@ -7029,6 +7079,28 @@ dependencies = [
"derive_utils",
]
+[[package]]
+name = "io-extras"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
+dependencies = [
+ "io-lifetimes 3.0.1",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "io-lifetimes"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
+
+[[package]]
+name = "io-lifetimes"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
+
[[package]]
name = "ipcrypt-rs"
version = "0.9.4"
@@ -8149,6 +8221,12 @@ dependencies = [
"rawpointer",
]
+[[package]]
+name = "maybe-owned"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -9282,10 +9360,12 @@ dependencies = [
"anyhow",
"async-trait",
"bytes",
+ "cap-std",
"chrono",
"common-base",
"common-error",
"common-macro",
+ "common-runtime",
"common-telemetry",
"common-test-util",
"derive_builder 0.20.2",
@@ -12632,6 +12712,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "rustix-linux-procfs"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
+dependencies = [
+ "once_cell",
+ "rustix 1.0.7",
+]
+
[[package]]
name = "rustls"
version = "0.23.28"
@@ -14723,6 +14813,7 @@ dependencies = [
"common-base",
"common-catalog",
"common-config",
+ "common-datasource",
"common-error",
"common-event-recorder",
"common-frontend",
@@ -16805,6 +16896,16 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "winx"
+version = "0.36.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
+dependencies = [
+ "bitflags 2.12.1",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "wit-bindgen"
version = "0.51.0"
diff --git a/Cargo.toml b/Cargo.toml
index 3c40e8df7a..ac7161aeaa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -119,6 +119,7 @@ bigdecimal = "0.4.2"
bitflags = "2.4.1"
bytemuck = "1.12"
bytes = { version = "1.11", features = ["serde"] }
+cap-std = "4.0"
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = { version = "0.10", features = ["case-insensitive"] }
clap = { version = "4.4", features = ["derive"] }
diff --git a/config/config.md b/config/config.md
index 8650fcdf08..6aa18e66dc 100644
--- a/config/config.md
+++ b/config/config.md
@@ -124,6 +124,7 @@
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).
Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").
Setting it to 0 disables the limit (unbounded, default behavior).
When this limit is reached, queries will fail with ResourceExhausted error.
NOTE: This does NOT limit memory used by table scans. |
| `storage` | -- | -- | The data storage options. |
| `storage.data_home` | String | `./greptimedb_data` | The working home directory. |
+| `storage.copy_root` | String | `./greptimedb_data/copy` | Root directory for standalone SQL access to local files.
Relative SQL paths are resolved below this directory. Absolute paths are accepted only when
they are inside this directory. Defaults to `/copy`.
Distributed deployments always reject SQL access to local files.
Upgrade note: COPY commands and existing external tables that reference paths outside this
directory will fail. Move those files below the copy root, set this option to a dedicated
directory containing them, or migrate the files to object storage before upgrading. |
| `storage.type` | String | `File` | The storage type used to store the data.
- `File`: the data is stored in the local file system.
- `S3`: the data is stored in the S3 object storage.
- `Gcs`: the data is stored in the Google Cloud Storage.
- `Azblob`: the data is stored in the Azure Blob Storage.
- `Oss`: the data is stored in the Aliyun OSS. |
| `storage.bucket` | String | Unset | The S3 bucket name.
**It's only used when the storage type is `S3`, `Oss` and `Gcs`**. |
| `storage.root` | String | Unset | The S3 data will be stored in the specified prefix, for example, `s3://${bucket}/${root}`.
**It's only used when the storage type is `S3`, `Oss` and `Azblob`**. |
diff --git a/config/standalone.example.toml b/config/standalone.example.toml
index 371b64864c..fccfe28695 100644
--- a/config/standalone.example.toml
+++ b/config/standalone.example.toml
@@ -442,6 +442,15 @@ memory_pool_size = "50%"
## The working home directory.
data_home = "./greptimedb_data"
+## Root directory for standalone SQL access to local files.
+## Relative SQL paths are resolved below this directory. Absolute paths are accepted only when
+## they are inside this directory. Defaults to `/copy`.
+## Distributed deployments always reject SQL access to local files.
+## Upgrade note: COPY commands and existing external tables that reference paths outside this
+## directory will fail. Move those files below the copy root, set this option to a dedicated
+## directory containing them, or migrate the files to object storage before upgrading.
+#+ copy_root = "./greptimedb_data/copy"
+
## The storage type used to store the data.
## - `File`: the data is stored in the local file system.
## - `S3`: the data is stored in the S3 object storage.
diff --git a/docs/how-to/migrate-local-sql-file-access.md b/docs/how-to/migrate-local-sql-file-access.md
new file mode 100644
index 0000000000..8051f21395
--- /dev/null
+++ b/docs/how-to/migrate-local-sql-file-access.md
@@ -0,0 +1,30 @@
+# Migrate Local SQL File Access
+
+SQL access to local files is sandboxed in standalone deployments and disabled in
+distributed deployments.
+
+## Standalone
+
+The default sandbox is `/copy`. Relative paths in `COPY` and
+external-table locations are resolved below this directory. Absolute paths work
+only when they are inside the sandbox.
+
+Before upgrading, identify existing `COPY` workflows and external tables that
+use local paths outside the default sandbox. Choose one of these migrations:
+
+- Move the files below `/copy` and update the SQL locations.
+- Set `storage.copy_root` to a dedicated local directory containing the files.
+- Move the files to S3, OSS, GCS, or AzBlob and update the SQL locations.
+
+Do not set `storage.copy_root` to `storage.data_home` or to a directory that
+contains GreptimeDB data, WAL, manifests, or configuration files. GreptimeDB
+rejects copy roots that expose its internal data directory.
+
+When `storage.data_home` is an object-storage URL, local SQL file access is
+disabled unless `storage.copy_root` explicitly names a local directory.
+
+## Distributed
+
+Distributed frontend and datanode processes reject local paths for `COPY TABLE`,
+`COPY QUERY`, `COPY DATABASE`, and external tables. Migrate these workflows and
+tables to S3, OSS, GCS, or AzBlob before upgrading.
diff --git a/src/cmd/Cargo.toml b/src/cmd/Cargo.toml
index 8002ab742f..24edcd5633 100644
--- a/src/cmd/Cargo.toml
+++ b/src/cmd/Cargo.toml
@@ -47,6 +47,7 @@ colored = "2.1.0"
common-base.workspace = true
common-catalog.workspace = true
common-config.workspace = true
+common-datasource.workspace = true
common-error.workspace = true
common-grpc.workspace = true
common-macro.workspace = true
diff --git a/src/cmd/src/standalone.rs b/src/cmd/src/standalone.rs
index 5690c2d0bc..aec373612a 100644
--- a/src/cmd/src/standalone.rs
+++ b/src/cmd/src/standalone.rs
@@ -28,6 +28,7 @@ use clap::Parser;
use common_base::Plugins;
use common_catalog::consts::{MIN_USER_FLOW_ID, MIN_USER_TABLE_ID};
use common_config::{Configurable, metadata_store_dir};
+use common_datasource::object_store::{LocalFileAccess, configured_local_path};
use common_error::ext::BoxedError;
use common_meta::DatanodeId;
use common_meta::cache::{LayeredCacheRegistryBuilder, LayeredCacheRegistryRef};
@@ -51,7 +52,7 @@ use common_telemetry::info;
use common_telemetry::logging::{DEFAULT_LOGGING_DIR, TracingOptions};
use common_time::timezone::set_default_timezone;
use common_version::{short_version, verbose_version};
-use datanode::config::DatanodeOptions;
+use datanode::config::{DatanodeOptions, StorageConfig};
use datanode::datanode::{Datanode, DatanodeBuilder};
use datanode::region_server::RegionServer;
use flow::{
@@ -69,7 +70,7 @@ use plugins::frontend::context::{
};
use plugins::standalone::context::DdlManagerConfigureContext;
use servers::tls::{TlsMode, TlsOption, merge_tls_option};
-use snafu::ResultExt;
+use snafu::{OptionExt, ResultExt};
use standalone::options::StandaloneOptions;
use standalone::{StandaloneInformationExtension, StandaloneRepartitionProcedureFactory};
use tracing_appender::non_blocking::WorkerGuard;
@@ -80,6 +81,58 @@ use crate::{App, create_resource_limit_metrics, error, log_versions, maybe_activ
pub const APP_NAME: &str = "greptime-standalone";
+fn standalone_local_file_access(
+ storage: &StorageConfig,
+) -> common_datasource::error::Result {
+ let data_home = configured_local_path(&storage.data_home)?;
+ let copy_root = match &storage.copy_root {
+ Some(root) => configured_local_path(root)?.with_context(|| {
+ common_datasource::error::InvalidLocalFileRootConfigSnafu {
+ root: root.clone(),
+ reason: "copy_root must be a local path or file URL".to_string(),
+ }
+ })?,
+ None => {
+ let Some(data_home) = &data_home else {
+ info!(
+ "SQL access to local files is disabled because storage.data_home is not a local path and storage.copy_root is unset"
+ );
+ return Ok(LocalFileAccess::Disabled);
+ };
+ data_home.join("copy")
+ }
+ };
+
+ let access = LocalFileAccess::sandboxed(©_root)?;
+ if let Some(data_home) = data_home {
+ let canonical_data_home = data_home.canonicalize().with_context(|_| {
+ common_datasource::error::InvalidLocalFileRootSnafu {
+ root: data_home.display().to_string(),
+ }
+ })?;
+ let canonical_copy_root = access.sandbox_root().with_context(|| {
+ common_datasource::error::InvalidLocalFileRootConfigSnafu {
+ root: copy_root.display().to_string(),
+ reason: "sandboxed local file access has no root".to_string(),
+ }
+ })?;
+ let default_copy_root = canonical_data_home.join("copy");
+ let exposes_internal_files = canonical_data_home.starts_with(canonical_copy_root)
+ || (canonical_copy_root.starts_with(&canonical_data_home)
+ && !canonical_copy_root.starts_with(default_copy_root));
+ if exposes_internal_files {
+ return common_datasource::error::InvalidLocalFileRootConfigSnafu {
+ root: copy_root.display().to_string(),
+ reason: "copy_root must not expose files in data_home outside data_home/copy"
+ .to_string(),
+ }
+ .fail();
+ }
+ }
+
+ Ok(access)
+}
+
#[derive(Parser)]
pub struct Command {
#[clap(subcommand)]
@@ -396,6 +449,9 @@ impl StartCommand {
// Ensure the data_home directory exists.
fs::create_dir_all(path::Path::new(data_home))
.context(error::CreateDirSnafu { dir: data_home })?;
+ let local_file_access = standalone_local_file_access(&dn_opts.storage)
+ .map_err(BoxedError::new)
+ .context(OtherSnafu)?;
let metadata_dir = metadata_store_dir(data_home);
let kv_backend = creator
@@ -427,6 +483,7 @@ impl StartCommand {
let mut builder = DatanodeBuilder::new(dn_opts, plugins.clone(), kv_backend.clone());
builder.with_cache_registry(layered_cache_registry.clone());
+ builder.with_local_file_access(local_file_access.clone());
if let Some(writable) = creator.open_regions_writable_override {
builder.with_open_regions_writable_override(writable);
}
@@ -595,7 +652,8 @@ impl StartCommand {
node_manager.clone(),
procedure_executor.clone(),
process_manager,
- );
+ )
+ .with_local_file_access(local_file_access);
plugins::setup_frontend_plugins_post_build(&mut plugins, &plugin_opts, &fe_instance)
.await
@@ -966,7 +1024,7 @@ mod tests {
use common_base::readable_size::ReadableSize;
use common_config::ENV_VAR_SEP;
use common_options::plugin_options::StandaloneFlag;
- use common_test_util::temp_dir::create_named_temp_file;
+ use common_test_util::temp_dir::{create_named_temp_file, create_temp_dir};
use common_wal::config::DatanodeWalConfig;
use frontend::frontend::FrontendOptions;
use object_store::config::{FileConfig, GcsConfig};
@@ -975,6 +1033,73 @@ mod tests {
use super::*;
use crate::options::GlobalOptions;
+ #[test]
+ fn test_standalone_local_file_access_config() {
+ let data_home = create_temp_dir("standalone_copy_root");
+ let storage = StorageConfig {
+ data_home: data_home.path().display().to_string(),
+ ..Default::default()
+ };
+ let access = standalone_local_file_access(&storage).unwrap();
+ assert_eq!(
+ access.sandbox_root().unwrap(),
+ data_home.path().join("copy").canonicalize().unwrap()
+ );
+
+ let remote_data_home = StorageConfig {
+ data_home: "s3://bucket/data".to_string(),
+ ..Default::default()
+ };
+ assert!(matches!(
+ standalone_local_file_access(&remote_data_home).unwrap(),
+ LocalFileAccess::Disabled
+ ));
+
+ let explicit_root = create_temp_dir("standalone_explicit_copy_root");
+ let remote_with_explicit_root = StorageConfig {
+ data_home: "s3://bucket/data".to_string(),
+ copy_root: Some(explicit_root.path().display().to_string()),
+ ..Default::default()
+ };
+ assert_eq!(
+ standalone_local_file_access(&remote_with_explicit_root)
+ .unwrap()
+ .sandbox_root()
+ .unwrap(),
+ explicit_root.path().canonicalize().unwrap()
+ );
+
+ let remote_copy_root = StorageConfig {
+ data_home: data_home.path().display().to_string(),
+ copy_root: Some("s3://bucket/copy".to_string()),
+ ..Default::default()
+ };
+ assert!(matches!(
+ standalone_local_file_access(&remote_copy_root),
+ Err(common_datasource::error::Error::InvalidLocalFileRootConfig { .. })
+ ));
+
+ let exposes_internal = StorageConfig {
+ data_home: data_home.path().display().to_string(),
+ copy_root: Some(data_home.path().join("data").display().to_string()),
+ ..Default::default()
+ };
+ assert!(matches!(
+ standalone_local_file_access(&exposes_internal),
+ Err(common_datasource::error::Error::InvalidLocalFileRootConfig { .. })
+ ));
+
+ let exposes_data_home = StorageConfig {
+ data_home: data_home.path().display().to_string(),
+ copy_root: Some(data_home.path().parent().unwrap().display().to_string()),
+ ..Default::default()
+ };
+ assert!(matches!(
+ standalone_local_file_access(&exposes_data_home),
+ Err(common_datasource::error::Error::InvalidLocalFileRootConfig { .. })
+ ));
+ }
+
#[tokio::test]
async fn test_try_from_start_command_to_anymap() {
let fe_opts = FrontendOptions {
diff --git a/src/common/datasource/src/error.rs b/src/common/datasource/src/error.rs
index 3c68bbc147..dbb0dec417 100644
--- a/src/common/datasource/src/error.rs
+++ b/src/common/datasource/src/error.rs
@@ -65,6 +65,62 @@ pub enum Error {
location: Location,
},
+ #[snafu(display(
+ "SQL access to the local filesystem is disabled for '{}'; use S3, OSS, GCS, or AzBlob instead",
+ path
+ ))]
+ LocalFileAccessDisabled {
+ path: String,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
+ #[snafu(display(
+ "Local filesystem path '{}' is outside the configured copy root or is unsafe: {}; use a path relative to the copy root or use S3, OSS, GCS, or AzBlob",
+ path,
+ reason
+ ))]
+ LocalFileAccessDenied {
+ path: String,
+ reason: String,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
+ #[snafu(display(
+ "Local filesystem path '{}' does not exist within the configured copy root",
+ path
+ ))]
+ LocalFilePathNotFound {
+ path: String,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
+ #[snafu(display("Location must include a file or object name: '{}'", path))]
+ MissingObjectName {
+ path: String,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
+ #[snafu(display("Invalid local filesystem root '{}'", root))]
+ InvalidLocalFileRoot {
+ root: String,
+ #[snafu(source)]
+ error: std::io::Error,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
+ #[snafu(display("Invalid local filesystem root '{}': {}", root, reason))]
+ InvalidLocalFileRootConfig {
+ root: String,
+ reason: String,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
#[snafu(display("Failed to build backend"))]
BuildBackend {
#[snafu(source)]
@@ -231,6 +287,12 @@ impl ErrorExt for Error {
| UnsupportedFormat { .. }
| InvalidConnection { .. }
| InvalidUrl { .. }
+ | LocalFileAccessDisabled { .. }
+ | LocalFileAccessDenied { .. }
+ | LocalFilePathNotFound { .. }
+ | MissingObjectName { .. }
+ | InvalidLocalFileRoot { .. }
+ | InvalidLocalFileRootConfig { .. }
| EmptyHostPath { .. }
| InferSchema { .. }
| ReadParquetSnafu { .. }
diff --git a/src/common/datasource/src/lister.rs b/src/common/datasource/src/lister.rs
index 97bbadfe12..2ffef475f7 100644
--- a/src/common/datasource/src/lister.rs
+++ b/src/common/datasource/src/lister.rs
@@ -72,7 +72,7 @@ impl Lister {
// make sure this file exists
let _ = self.object_store.stat(filename).await.with_context(|_| {
error::ListObjectsSnafu {
- path: format!("{}{}", &self.root, filename),
+ path: self.root.clone(),
}
})?;
diff --git a/src/common/datasource/src/object_store.rs b/src/common/datasource/src/object_store.rs
index 56eaf3968b..97bf779413 100644
--- a/src/common/datasource/src/object_store.rs
+++ b/src/common/datasource/src/object_store.rs
@@ -19,9 +19,13 @@ pub mod oss;
pub mod s3;
use std::collections::HashMap;
+use std::path::{Component, Path, PathBuf};
+use std::sync::Arc;
+use common_telemetry::debug;
use lazy_static::lazy_static;
use object_store::ObjectStore;
+use object_store::secure_fs::SecureFsRoot;
use regex::Regex;
use snafu::{OptionExt, ResultExt};
use url::{ParseError, Url};
@@ -35,16 +39,244 @@ use crate::object_store::oss::build_oss_backend;
use crate::util::find_dir_and_filename;
pub const FS_SCHEMA: &str = "FS";
+pub const FILE_SCHEMA: &str = "FILE";
pub const S3_SCHEMA: &str = "S3";
pub const OSS_SCHEMA: &str = "OSS";
pub const GCS_SCHEMA: &str = "GCS";
pub const AZBLOB_SCHEMA: &str = "AZBLOB";
+/// An object store rooted at the target's parent, together with the optional
+/// target path relative to that root.
+pub struct BuiltBackend {
+ pub object_store: ObjectStore,
+ pub object_path: Option,
+}
+
+/// Controls whether SQL paths may access the local filesystem.
+#[derive(Clone, Debug, Default)]
+pub enum LocalFileAccess {
+ /// Local filesystem paths are rejected.
+ #[default]
+ Disabled,
+ /// Local filesystem paths are confined to a server-configured root.
+ Sandboxed { root: LocalFileRoot },
+}
+
+/// An opened server-controlled root for sandboxed SQL file access.
+#[derive(Clone, Debug)]
+pub struct LocalFileRoot {
+ root: Arc,
+ configured_path: Arc,
+}
+
+impl LocalFileAccess {
+ /// Creates a sandbox rooted at a server-controlled local directory.
+ pub fn sandboxed(root: impl AsRef) -> Result {
+ let root_path = root.as_ref();
+ let configured_path =
+ std::path::absolute(root_path).with_context(|_| error::InvalidLocalFileRootSnafu {
+ root: root_path.display().to_string(),
+ })?;
+ let root =
+ SecureFsRoot::open(root_path).with_context(|_| error::InvalidLocalFileRootSnafu {
+ root: root_path.display().to_string(),
+ })?;
+ Ok(Self::Sandboxed {
+ root: LocalFileRoot {
+ root: Arc::new(root),
+ configured_path: Arc::new(configured_path),
+ },
+ })
+ }
+
+ /// Returns the canonical path of the configured sandbox root.
+ pub fn sandbox_root(&self) -> Option<&Path> {
+ match self {
+ Self::Disabled => None,
+ Self::Sandboxed { root } => Some(root.root.path()),
+ }
+ }
+
+ fn authorize(&self, location: &str, path: &Path, trailing_slash: bool) -> Result {
+ let LocalFileAccess::Sandboxed { root } = self else {
+ return error::LocalFileAccessDisabledSnafu {
+ path: location.to_string(),
+ }
+ .fail();
+ };
+
+ let path = normalize_untrusted_path(path).map_err(|reason| {
+ error::LocalFileAccessDeniedSnafu {
+ path: location.to_string(),
+ reason,
+ }
+ .build()
+ })?;
+ let relative = if path.is_absolute() {
+ strip_local_prefix(&path, root.configured_path.as_path())
+ .or_else(|| strip_local_prefix(&path, root.root.path()))
+ .ok_or_else(|| {
+ error::LocalFileAccessDeniedSnafu {
+ path: location.to_string(),
+ reason: "absolute path is outside the configured copy root".to_string(),
+ }
+ .build()
+ })?
+ } else {
+ path.as_path()
+ };
+
+ let mut authorized = relative
+ .components()
+ .filter_map(|component| match component {
+ Component::CurDir => None,
+ Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
+ _ => None,
+ })
+ .collect::>()
+ .join("/");
+ if trailing_slash && !authorized.is_empty() {
+ authorized.push('/');
+ }
+ Ok(authorized)
+ }
+
+ async fn open_backend_root(
+ &self,
+ location: &str,
+ relative_root: &str,
+ create: bool,
+ ) -> Result {
+ let LocalFileAccess::Sandboxed { root } = self else {
+ return error::LocalFileAccessDisabledSnafu {
+ path: location.to_string(),
+ }
+ .fail();
+ };
+
+ let root = root.root.clone();
+ let relative_root = relative_root.trim_matches('/').to_string();
+ common_runtime::spawn_blocking_global(move || {
+ if create {
+ root.create_subdir(relative_root)
+ } else {
+ root.open_subdir(relative_root)
+ }
+ })
+ .await
+ .context(error::JoinHandleSnafu)?
+ .map_err(|error| {
+ debug!(
+ "Failed to open an authorized local SQL path inside the copy root, path: {location}, error: {error:?}"
+ );
+ if error.kind() == std::io::ErrorKind::NotFound {
+ return error::LocalFilePathNotFoundSnafu { path: location }.build();
+ }
+ error::LocalFileAccessDeniedSnafu {
+ path: location.to_string(),
+ reason: "path could not be safely resolved within the configured copy root"
+ .to_string(),
+ }
+ .build()
+ })
+ }
+}
+
+/// Converts a configured location into a local path.
+///
+/// Bare paths and `file://` URLs are local. Other URL schemes return `None`.
+pub fn configured_local_path(location: &str) -> Result