fix: sandbox SQL local filesystem access (#8708)

* fix: sandbox SQL local filesystem access

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: address local file sandbox review findings

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: support Windows local copy paths

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: improve sandbox path errors

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* refactor: simplify local path error context

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* perf: stream secure filesystem listings

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* style: derive local file access default

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: improve local file access errors

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: address local file access review findings

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test: simplify local file access coverage

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: harden sandboxed local file backends

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: reject directory copy targets before creation

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: avoid implicit string clone in file table listing

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
This commit is contained in:
jeremyhi
2026-07-31 21:23:15 +08:00
committed by GitHub
parent 1aa35716cb
commit 448f973593
64 changed files with 2153 additions and 140 deletions
+4
View File
@@ -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
Generated
+101
View File
@@ -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"
+1
View File
@@ -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"] }
+1
View File
@@ -124,6 +124,7 @@
| `query.memory_pool_size` | String | `50%` | Memory pool size for query execution operators (aggregation, sorting, join).<br/>Supports absolute size (e.g., "2GB", "4GB") or percentage of system memory (e.g., "20%").<br/>Setting it to 0 disables the limit (unbounded, default behavior).<br/>When this limit is reached, queries will fail with ResourceExhausted error.<br/>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.<br/>Relative SQL paths are resolved below this directory. Absolute paths are accepted only when<br/>they are inside this directory. Defaults to `<data_home>/copy`.<br/>Distributed deployments always reject SQL access to local files.<br/>Upgrade note: COPY commands and existing external tables that reference paths outside this<br/>directory will fail. Move those files below the copy root, set this option to a dedicated<br/>directory containing them, or migrate the files to object storage before upgrading. |
| `storage.type` | String | `File` | The storage type used to store the data.<br/>- `File`: the data is stored in the local file system.<br/>- `S3`: the data is stored in the S3 object storage.<br/>- `Gcs`: the data is stored in the Google Cloud Storage.<br/>- `Azblob`: the data is stored in the Azure Blob Storage.<br/>- `Oss`: the data is stored in the Aliyun OSS. |
| `storage.bucket` | String | Unset | The S3 bucket name.<br/>**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}`.<br/>**It's only used when the storage type is `S3`, `Oss` and `Azblob`**. |
+9
View File
@@ -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 `<data_home>/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.
@@ -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 `<storage.data_home>/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 `<storage.data_home>/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.
+1
View File
@@ -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
+129 -4
View File
@@ -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<LocalFileAccess> {
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(&copy_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 {
+62
View File
@@ -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 { .. }
+1 -1
View File
@@ -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(),
}
})?;
+654 -15
View File
@@ -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<String>,
}
/// 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<SecureFsRoot>,
configured_path: Arc<PathBuf>,
}
impl LocalFileAccess {
/// Creates a sandbox rooted at a server-controlled local directory.
pub fn sandboxed(root: impl AsRef<Path>) -> Result<Self> {
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<String> {
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::<Vec<_>>()
.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<SecureFsRoot> {
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<Option<PathBuf>> {
#[cfg(windows)]
if Path::new(location).is_absolute() {
return Ok(Some(PathBuf::from(location)));
}
let (schema, _, path) = parse_url(location)?;
match schema.to_uppercase().as_str() {
FS_SCHEMA => Ok(Some(PathBuf::from(path))),
FILE_SCHEMA => {
let url = Url::parse(location).context(error::InvalidUrlSnafu { url: location })?;
url.to_file_path().map(Some).map_err(|_| {
error::InvalidLocalFileRootConfigSnafu {
root: location.to_string(),
reason: "file URL must contain a local absolute path".to_string(),
}
.build()
})
}
_ => Ok(None),
}
}
fn strip_local_prefix<'a>(path: &'a Path, prefix: &Path) -> Option<&'a Path> {
#[cfg(not(windows))]
{
path.strip_prefix(prefix).ok()
}
#[cfg(windows)]
{
let mut path_components = path.components();
for prefix_component in prefix.components() {
let path_component = path_components.next()?;
if !windows_component_eq(path_component, prefix_component) {
return None;
}
}
Some(path_components.as_path())
}
}
#[cfg(windows)]
fn windows_component_eq(left: Component<'_>, right: Component<'_>) -> bool {
match (left, right) {
(Component::Prefix(left), Component::Prefix(right)) => {
windows_os_str_eq(left.as_os_str(), right.as_os_str())
}
(Component::Normal(left), Component::Normal(right)) => windows_os_str_eq(left, right),
(Component::RootDir, Component::RootDir)
| (Component::CurDir, Component::CurDir)
| (Component::ParentDir, Component::ParentDir) => true,
_ => false,
}
}
#[cfg(windows)]
fn windows_os_str_eq(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
use std::os::windows::ffi::OsStrExt;
fn ascii_lowercase(value: u16) -> u16 {
if (u16::from(b'A')..=u16::from(b'Z')).contains(&value) {
value + u16::from(b'a' - b'A')
} else {
value
}
}
left.encode_wide()
.map(ascii_lowercase)
.eq(right.encode_wide().map(ascii_lowercase))
}
fn normalize_untrusted_path(path: &Path) -> std::result::Result<PathBuf, String> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir => return Err("'..' path components are not allowed".to_string()),
}
}
Ok(normalized)
}
/// Returns `(schema, Option<host>, path)`
pub fn parse_url(url: &str) -> Result<(String, Option<String>, String)> {
#[cfg(windows)]
{
// On Windows, the url may start with `C:/`.
// On Windows, the URL may start with `C:/` or `C:\`.
if handle_windows_path(url).is_some() {
return Ok((FS_SCHEMA.to_string(), None, url.to_string()));
}
@@ -63,47 +295,134 @@ pub fn parse_url(url: &str) -> Result<(String, Option<String>, String)> {
}
}
pub fn build_backend(url: &str, connection: &HashMap<String, String>) -> Result<ObjectStore> {
let (schema, host, path) = parse_url(url)?;
let (root, _) = find_dir_and_filename(&path);
pub async fn build_backend(
url: &str,
connection: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
) -> Result<ObjectStore> {
Ok(
build_backend_inner(url, connection, local_file_access, false, false)
.await?
.object_store,
)
}
match schema.to_uppercase().as_str() {
/// Builds a backend and returns the target path relative to the backend root.
pub async fn build_backend_with_path(
url: &str,
connection: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
) -> Result<BuiltBackend> {
build_backend_inner(url, connection, local_file_access, false, false).await
}
/// Builds a backend for an operation that may create the target directory.
pub async fn build_backend_for_write(
url: &str,
connection: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
) -> Result<ObjectStore> {
Ok(
build_backend_inner(url, connection, local_file_access, true, false)
.await?
.object_store,
)
}
/// Builds a writable backend and returns the target path relative to the backend root.
pub async fn build_backend_for_write_with_path(
url: &str,
connection: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
) -> Result<BuiltBackend> {
build_backend_inner(url, connection, local_file_access, true, true).await
}
async fn build_backend_inner(
url: &str,
connection: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
create_local_root: bool,
require_object_path: bool,
) -> Result<BuiltBackend> {
let (schema, host, path) = parse_url(url)?;
let normalized_schema = schema.to_uppercase();
if normalized_schema == FS_SCHEMA || normalized_schema == FILE_SCHEMA {
let (local_path, trailing_slash) = if normalized_schema == FILE_SCHEMA {
let url = Url::parse(url).context(error::InvalidUrlSnafu { url })?;
let path = url.to_file_path().map_err(|_| {
error::LocalFileAccessDeniedSnafu {
path: url.to_string(),
reason: "file URL must contain a local absolute path".to_string(),
}
.build()
})?;
(path, url.path().ends_with('/'))
} else {
(
PathBuf::from(&path),
path.ends_with('/') || cfg!(windows) && path.ends_with(std::path::MAIN_SEPARATOR),
)
};
let authorized = local_file_access.authorize(url, &local_path, trailing_slash)?;
let (root, object_path) = find_dir_and_filename(&authorized);
if require_object_path && object_path.is_none() {
return error::MissingObjectNameSnafu {
path: url.to_string(),
}
.fail();
}
let root = local_file_access
.open_backend_root(url, &root, create_local_root)
.await?;
return Ok(BuiltBackend {
object_store: build_fs_backend(&root)?,
object_path,
});
}
let (root, object_path) = find_dir_and_filename(&path);
let object_store = match normalized_schema.as_str() {
S3_SCHEMA => {
let host = host.context(error::EmptyHostPathSnafu {
url: url.to_string(),
})?;
Ok(build_s3_backend(&host, &root, connection)?)
build_s3_backend(&host, &root, connection)?
}
OSS_SCHEMA => {
let host = host.context(error::EmptyHostPathSnafu {
url: url.to_string(),
})?;
Ok(build_oss_backend(&host, &root, connection)?)
build_oss_backend(&host, &root, connection)?
}
GCS_SCHEMA => {
let host = host.context(error::EmptyHostPathSnafu {
url: url.to_string(),
})?;
Ok(build_gcs_backend(&host, &root, connection)?)
build_gcs_backend(&host, &root, connection)?
}
AZBLOB_SCHEMA => {
let host = host.context(error::EmptyHostPathSnafu {
url: url.to_string(),
})?;
Ok(build_azblob_backend(&host, &root, connection)?)
build_azblob_backend(&host, &root, connection)?
}
FS_SCHEMA => Ok(build_fs_backend(&root)?),
_ => error::UnsupportedBackendProtocolSnafu {
protocol: schema,
url,
}
.fail(),
}
.fail()?,
};
Ok(BuiltBackend {
object_store,
object_path,
})
}
lazy_static! {
static ref DISK_SYMBOL_PATTERN: Regex = Regex::new("^([A-Za-z]:/)").unwrap();
static ref DISK_SYMBOL_PATTERN: Regex = Regex::new(r"^([A-Za-z]:[/\\])").unwrap();
}
pub fn handle_windows_path(url: &str) -> Option<String> {
@@ -114,7 +433,19 @@ pub fn handle_windows_path(url: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::handle_windows_path;
use std::collections::HashMap;
use std::fs;
use common_error::ext::{ErrorExt, RetryHint};
use common_error::status_code::StatusCode;
use common_test_util::temp_dir::create_temp_dir;
use url::Url;
use super::{
LocalFileAccess, build_backend, build_backend_for_write, build_backend_for_write_with_path,
build_backend_with_path, handle_windows_path,
};
use crate::error::Error;
#[test]
fn test_handle_windows_path() {
@@ -122,7 +453,315 @@ mod tests {
handle_windows_path("C:/to/path/file"),
Some("C:/".to_string())
);
assert_eq!(
handle_windows_path(r"C:\to\path\file"),
Some(r"C:\".to_string())
);
assert_eq!(handle_windows_path("https://google.com"), None);
assert_eq!(handle_windows_path("s3://bucket/path/to"), None);
}
#[cfg(windows)]
#[test]
fn test_windows_local_path_detection_and_prefix() {
use std::path::{Path, PathBuf};
let location = r"C:\gtdata";
assert_eq!(
super::configured_local_path(location).unwrap(),
Some(PathBuf::from(location))
);
assert_eq!(
super::parse_url(location).unwrap(),
("FS".to_string(), None, location.to_string())
);
assert_eq!(
super::strip_local_prefix(
Path::new(r"c:\Data\Copy\nested\data.parquet"),
Path::new(r"C:\data\copy"),
),
Some(Path::new(r"nested\data.parquet"))
);
}
#[tokio::test]
async fn test_local_file_access_policy() {
let data_home = create_temp_dir("local_file_access_policy");
let copy_root = data_home.path().join("copy");
let internal_dir = data_home.path().join("data");
fs::create_dir_all(&internal_dir).unwrap();
fs::write(internal_dir.join("secret"), "secret").unwrap();
let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
let connection = HashMap::new();
let store = build_backend_for_write("nested/data.txt", &connection, &access)
.await
.unwrap();
store.write("data.txt", "first").await.unwrap();
store.write("data.txt", "second").await.unwrap();
assert_eq!(
fs::read_to_string(copy_root.join("nested/data.txt")).unwrap(),
"second"
);
let missing = copy_root.join("missing/directory");
let error = build_backend("missing/directory/data.txt", &connection, &access)
.await
.unwrap_err();
assert!(matches!(&error, Error::LocalFilePathNotFound { .. }));
assert_eq!(error.status_code(), StatusCode::InvalidArguments);
assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
assert!(
error.to_string().contains("does not exist"),
"unexpected error: {error}"
);
assert!(!missing.exists());
let absolute = copy_root.join("nested/data.txt");
let store = build_backend(absolute.to_str().unwrap(), &connection, &access)
.await
.unwrap();
assert_eq!(store.read("data.txt").await.unwrap().to_vec(), b"second");
let file_url = Url::from_file_path(&absolute).unwrap().to_string();
let store = build_backend(&file_url, &connection, &access)
.await
.unwrap();
assert_eq!(store.read("data.txt").await.unwrap().to_vec(), b"second");
let internal_file = internal_dir.join("secret");
assert!(matches!(
build_backend(internal_file.to_str().unwrap(), &connection, &access).await,
Err(Error::LocalFileAccessDenied { .. })
));
assert!(
build_backend("../escape/data.txt", &connection, &access)
.await
.is_err()
);
let outside = data_home.path().join("outside/new");
assert!(
build_backend(outside.to_str().unwrap(), &connection, &access)
.await
.is_err()
);
assert!(!outside.parent().unwrap().exists());
let prefix_escape = data_home.path().join("copy-not-the-root/new");
assert!(matches!(
build_backend(prefix_escape.to_str().unwrap(), &connection, &access).await,
Err(Error::LocalFileAccessDenied { .. })
));
assert!(!prefix_escape.parent().unwrap().exists());
let disabled = LocalFileAccess::Disabled;
let error = build_backend(internal_file.to_str().unwrap(), &connection, &disabled)
.await
.unwrap_err();
assert!(matches!(&error, Error::LocalFileAccessDisabled { .. }));
assert_eq!(error.status_code(), StatusCode::InvalidArguments);
assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
assert!(matches!(
build_backend(&file_url, &connection, &disabled).await,
Err(Error::LocalFileAccessDisabled { .. })
));
assert!(matches!(
build_backend(outside.to_str().unwrap(), &connection, &disabled).await,
Err(Error::LocalFileAccessDisabled { .. })
));
assert!(!outside.parent().unwrap().exists());
}
#[tokio::test]
async fn test_file_url_returns_decoded_backend_relative_path() {
let temp_dir = create_temp_dir("file_url_backend_relative_path");
let copy_root = temp_dir.path().join("copy root");
let file = copy_root.join("nested dir/data file.txt");
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "data").unwrap();
let location = Url::from_file_path(&file).unwrap().to_string();
assert!(location.contains("%20"));
let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
let backend = build_backend_with_path(&location, &HashMap::new(), &access)
.await
.unwrap();
assert_eq!(backend.object_path.as_deref(), Some("data file.txt"));
assert_eq!(
backend
.object_store
.read(backend.object_path.as_deref().unwrap())
.await
.unwrap()
.to_vec(),
b"data"
);
}
#[tokio::test]
async fn test_write_with_path_rejects_directory_before_creation() {
let temp_dir = create_temp_dir("write_with_path_rejects_directory");
let copy_root = temp_dir.path().join("copy");
let target = copy_root.join("new directory");
let location = Url::from_directory_path(&target).unwrap().to_string();
let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
let result = build_backend_for_write_with_path(&location, &HashMap::new(), &access).await;
assert!(matches!(result, Err(Error::MissingObjectName { .. })));
assert!(!target.exists());
let result = build_backend_for_write_with_path(
&location,
&HashMap::new(),
&LocalFileAccess::Disabled,
)
.await;
assert!(matches!(result, Err(Error::LocalFileAccessDisabled { .. })));
assert!(!target.exists());
let relative_target = copy_root.join("relative directory");
let result =
build_backend_for_write_with_path("relative directory/", &HashMap::new(), &access)
.await;
assert!(matches!(result, Err(Error::MissingObjectName { .. })));
assert!(!relative_target.exists());
let allowed_directory = copy_root.join("allowed directory");
build_backend_for_write("allowed directory/", &HashMap::new(), &access)
.await
.unwrap();
assert!(allowed_directory.is_dir());
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_backslash_path_returns_backend_relative_path() {
let temp_dir = create_temp_dir("windows_backend_relative_path");
let copy_root = temp_dir.path().join("copy");
let directory = copy_root.join("nested");
let file = directory.join("data.txt");
fs::create_dir_all(&directory).unwrap();
fs::write(&file, "data").unwrap();
let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
let connection = HashMap::new();
let file_location = file.to_str().unwrap();
assert!(file_location.contains('\\'));
let backend = build_backend_with_path(file_location, &connection, &access)
.await
.unwrap();
assert_eq!(backend.object_path.as_deref(), Some("data.txt"));
assert_eq!(
backend
.object_store
.read(backend.object_path.as_deref().unwrap())
.await
.unwrap()
.to_vec(),
b"data"
);
let new_directory = copy_root.join("new directory");
let directory_location = format!("{}\\", new_directory.display());
assert!(matches!(
build_backend_for_write_with_path(&directory_location, &connection, &access).await,
Err(Error::MissingObjectName { .. })
));
assert!(!new_directory.exists());
}
#[tokio::test]
async fn test_object_storage_ignores_local_file_policy() {
let cases = [
(
"s3://bucket/path/data%20file.parquet",
HashMap::from([
("region".to_string(), "us-east-1".to_string()),
("disable_ec2_metadata".to_string(), "true".to_string()),
]),
"data%20file.parquet",
),
(
"oss://bucket/path/file.parquet",
HashMap::from([
("endpoint".to_string(), "http://oss.example.com".to_string()),
("allow_anonymous".to_string(), "true".to_string()),
]),
"file.parquet",
),
(
"gcs://bucket/path/file.parquet",
HashMap::from([(
"endpoint".to_string(),
"http://storage.example.com".to_string(),
)]),
"file.parquet",
),
(
"azblob://container/path/file.parquet",
HashMap::from([
(
"endpoint".to_string(),
"http://storage.example.com".to_string(),
),
("account_name".to_string(), "test".to_string()),
]),
"file.parquet",
),
];
for (location, connection, expected_path) in cases {
let backend = build_backend_for_write_with_path(
location,
&connection,
&LocalFileAccess::Disabled,
)
.await
.unwrap();
assert_eq!(backend.object_path.as_deref(), Some(expected_path));
}
}
#[cfg(unix)]
#[tokio::test]
async fn test_local_file_access_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let temp_dir = create_temp_dir("local_file_access_symlink");
let copy_root = temp_dir.path().join("copy");
let outside = temp_dir.path().join("outside");
fs::create_dir_all(&copy_root).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("secret"), "secret").unwrap();
symlink(&outside, copy_root.join("escape")).unwrap();
symlink(outside.join("secret"), copy_root.join("secret-link")).unwrap();
let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
let connection = HashMap::new();
assert!(
build_backend("escape/secret", &connection, &access)
.await
.is_err()
);
assert!(
build_backend_for_write("escape/new", &connection, &access)
.await
.is_err()
);
assert!(!outside.join("new").exists());
let store = build_backend("secret-link", &connection, &access)
.await
.unwrap();
assert!(store.read("secret-link").await.is_err());
assert!(store.write("secret-link", "overwritten").await.is_err());
assert_eq!(
fs::read_to_string(outside.join("secret")).unwrap(),
"secret"
);
}
}
+4 -9
View File
@@ -13,16 +13,11 @@
// limitations under the License.
use object_store::ObjectStore;
use object_store::services::Fs;
use object_store::secure_fs::SecureFsRoot;
use object_store::util::with_instrument_layers;
use snafu::ResultExt;
use crate::error::{BuildBackendSnafu, Result};
use crate::error::Result;
pub fn build_fs_backend(root: &str) -> Result<ObjectStore> {
let builder = Fs::default();
let object_store = ObjectStore::new(builder.root(root))
.context(BuildBackendSnafu)?
.finish();
Ok(with_instrument_layers(object_store, true))
pub fn build_fs_backend(root: &SecureFsRoot) -> Result<ObjectStore> {
Ok(with_instrument_layers(root.build_operator(), true))
}
+1
View File
@@ -20,6 +20,7 @@ client.workspace = true
common-base.workspace = true
common-catalog.workspace = true
common-config.workspace = true
common-datasource.workspace = true
common-error.workspace = true
common-function.workspace = true
common-greptimedb-telemetry.workspace = true
+6
View File
@@ -39,6 +39,11 @@ use servers::http::HttpOptions;
pub struct StorageConfig {
/// The working directory of database
pub data_home: String,
/// Root directory for standalone SQL access to local files.
///
/// Defaults to `<data_home>/copy` when `data_home` is a local path.
/// Distributed deployments always disable SQL access to local files.
pub copy_root: Option<String>,
#[serde(flatten)]
pub store: ObjectStoreConfig,
/// Object storage providers
@@ -56,6 +61,7 @@ impl Default for StorageConfig {
fn default() -> Self {
Self {
data_home: DEFAULT_DATA_HOME.to_string(),
copy_root: None,
store: ObjectStoreConfig::default(),
providers: vec![],
}
+9
View File
@@ -19,6 +19,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use common_base::Plugins;
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::BoxedError;
use common_greptimedb_telemetry::GreptimeDBTelemetryTask;
use common_meta::cache::{LayeredCacheRegistry, SchemaCacheRef, TableSchemaCacheRef};
@@ -164,6 +165,7 @@ pub struct DatanodeBuilder {
cache_registry: Option<Arc<LayeredCacheRegistry>>,
topic_stats_reporter: Option<Box<dyn TopicStatsReporter>>,
open_regions_writable_override: Option<bool>,
local_file_access: LocalFileAccess,
#[cfg(feature = "enterprise")]
extension_range_provider_factory: Option<mito2::extension::BoxedExtensionRangeProviderFactory>,
}
@@ -178,6 +180,7 @@ impl DatanodeBuilder {
kv_backend,
cache_registry: None,
open_regions_writable_override: None,
local_file_access: LocalFileAccess::Disabled,
#[cfg(feature = "enterprise")]
extension_range_provider_factory: None,
topic_stats_reporter: None,
@@ -198,6 +201,11 @@ impl DatanodeBuilder {
self
}
pub fn with_local_file_access(&mut self, local_file_access: LocalFileAccess) -> &mut Self {
self.local_file_access = local_file_access;
self
}
pub fn kv_backend(&self) -> &KvBackendRef {
&self.kv_backend
}
@@ -528,6 +536,7 @@ impl DatanodeBuilder {
let file_engine = FileRegionEngine::new(
file_engine_config,
object_store_manager.default_object_store().clone(), // TODO: implement custom storage for file engine
self.local_file_access.clone(),
);
Ok(vec![
+13 -4
View File
@@ -19,6 +19,7 @@ use std::sync::{Arc, RwLock};
use api::region::RegionResponse;
use async_trait::async_trait;
use common_catalog::consts::FILE_ENGINE;
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::BoxedError;
use common_recordbatch::SendableRecordBatchStream;
use common_telemetry::{error, info};
@@ -48,9 +49,13 @@ pub struct FileRegionEngine {
}
impl FileRegionEngine {
pub fn new(_config: EngineConfig, object_store: ObjectStore) -> Self {
pub fn new(
_config: EngineConfig,
object_store: ObjectStore,
local_file_access: LocalFileAccess,
) -> Self {
Self {
inner: Arc::new(EngineInner::new(object_store)),
inner: Arc::new(EngineInner::new(object_store, local_file_access)),
}
}
@@ -64,7 +69,8 @@ impl FileRegionEngine {
.await
.context(RegionNotFoundSnafu { region_id })
.map_err(BoxedError::new)?
.query(request)
.query(request, &self.inner.local_file_access)
.await
.map_err(BoxedError::new)
}
}
@@ -182,6 +188,8 @@ struct EngineInner {
region_mutex: Mutex<()>,
object_store: ObjectStore,
local_file_access: LocalFileAccess,
}
type EngineInnerRef = Arc<EngineInner>;
@@ -205,11 +213,12 @@ fn ensure_region_requirements(
}
impl EngineInner {
fn new(object_store: ObjectStore) -> Self {
fn new(object_store: ObjectStore, local_file_access: LocalFileAccess) -> Self {
Self {
regions: RwLock::new(HashMap::new()),
region_mutex: Mutex::new(()),
object_store,
local_file_access,
}
}
+9 -3
View File
@@ -19,7 +19,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use common_datasource::object_store::build_backend;
use common_datasource::object_store::{LocalFileAccess, build_backend};
use common_recordbatch::adapter::RecordBatchMetrics;
use common_recordbatch::error::{self as recordbatch_error, Result as RecordBatchResult};
use common_recordbatch::{
@@ -41,8 +41,14 @@ use crate::error::{BuildBackendSnafu, ProjectSchemaSnafu, ProjectionOutOfBoundsS
use crate::region::FileRegion;
impl FileRegion {
pub fn query(&self, request: ScanRequest) -> Result<SendableRecordBatchStream> {
let store = build_backend(&self.url, &self.options).context(BuildBackendSnafu)?;
pub async fn query(
&self,
request: ScanRequest,
local_file_access: &LocalFileAccess,
) -> Result<SendableRecordBatchStream> {
let store = build_backend(&self.url, &self.options, local_file_access)
.await
.context(BuildBackendSnafu)?;
let projection = request.projection.as_deref();
let file_projection = self.projection_pushdown_to_file(projection)?;
+39
View File
@@ -107,7 +107,11 @@ impl FileRegion {
mod tests {
use std::assert_matches;
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::{ErrorExt, RetryHint};
use common_error::status_code::StatusCode;
use store_api::region_request::PathType;
use store_api::storage::ScanRequest;
use super::*;
use crate::error::Error;
@@ -155,6 +159,41 @@ mod tests {
assert_matches!(err, Error::ManifestExists { .. });
}
#[tokio::test]
async fn test_persisted_local_region_rejected_when_disabled() {
let (_dir, object_store) = new_test_object_store("test_disabled_local_region");
let request = RegionCreateRequest {
engine: "file".to_string(),
column_metadatas: new_test_column_metadata(),
primary_key: vec![1],
options: new_test_options(),
table_dir: "disabled_local_region/".to_string(),
path_type: PathType::Bare,
partition_expr_json: Some("".to_string()),
requirements: Default::default(),
};
let region = FileRegion::create(RegionId::new(1, 0), request, &object_store)
.await
.unwrap();
let error = match region
.query(ScanRequest::default(), &LocalFileAccess::Disabled)
.await
{
Ok(_) => panic!("local file query must be rejected"),
Err(error) => error,
};
assert_matches!(
&error,
Error::BuildBackend {
source: common_datasource::error::Error::LocalFileAccessDisabled { .. },
..
}
);
assert_eq!(error.status_code(), StatusCode::InvalidArguments);
assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
}
#[tokio::test]
async fn test_open_region() {
let (_dir, object_store) = new_test_object_store("test_open_region");
+1
View File
@@ -20,6 +20,7 @@ chrono.workspace = true
client.workspace = true
common-base.workspace = true
common-config.workspace = true
common-datasource.workspace = true
common-decimal.workspace = true
common-error.workspace = true
common-frontend.workspace = true
+2
View File
@@ -22,6 +22,7 @@ use api::v1::{RowDeleteRequests, RowInsertRequests};
use cache::{PARTITION_INFO_CACHE_NAME, TABLE_FLOWNODE_SET_CACHE_NAME, TABLE_ROUTE_CACHE_NAME};
use catalog::CatalogManagerRef;
use common_base::Plugins;
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::BoxedError;
use common_meta::cache::{LayeredCacheRegistryRef, TableFlownodeSetCacheRef, TableRouteCacheRef};
use common_meta::key::TableMetadataManagerRef;
@@ -632,6 +633,7 @@ impl FrontendInvoker {
partition_manager,
None,
origin_frontend_addr,
LocalFileAccess::Disabled,
));
let invoker = FrontendInvoker::new(inserter, deleter, statement_executor);
+9
View File
@@ -19,6 +19,7 @@ use cache::{PARTITION_INFO_CACHE_NAME, TABLE_FLOWNODE_SET_CACHE_NAME, TABLE_ROUT
use catalog::CatalogManagerRef;
use catalog::process_manager::ProcessManagerRef;
use common_base::Plugins;
use common_datasource::object_store::LocalFileAccess;
use common_event_recorder::EventRecorderImpl;
use common_meta::cache::{LayeredCacheRegistryRef, TableRouteCacheRef};
use common_meta::cache_invalidator::{CacheInvalidatorRef, DummyCacheInvalidator};
@@ -65,6 +66,7 @@ pub struct FrontendBuilder {
plugins: Option<Plugins>,
procedure_executor: ProcedureExecutorRef,
process_manager: ProcessManagerRef,
local_file_access: LocalFileAccess,
}
impl FrontendBuilder {
@@ -88,6 +90,7 @@ impl FrontendBuilder {
plugins: None,
procedure_executor,
process_manager,
local_file_access: LocalFileAccess::Disabled,
}
}
@@ -134,6 +137,11 @@ impl FrontendBuilder {
}
}
pub fn with_local_file_access(mut self, local_file_access: LocalFileAccess) -> Self {
self.local_file_access = local_file_access;
self
}
pub fn options(&self) -> &FrontendOptions {
&self.options
}
@@ -271,6 +279,7 @@ impl FrontendBuilder {
partition_manager,
Some(process_manager.clone()),
frontend_peer_addr.clone(),
self.local_file_access,
);
let statement_executor =
+2
View File
@@ -15,10 +15,12 @@ testing = ["derive_builder"]
[dependencies]
async-trait.workspace = true
bytes.workspace = true
cap-std.workspace = true
chrono.workspace = true
common-base.workspace = true
common-error.workspace = true
common-macro.workspace = true
common-runtime.workspace = true
common-telemetry.workspace = true
datafusion_object_store.workspace = true
derive_builder = { workspace = true, optional = true }
+1
View File
@@ -24,6 +24,7 @@ pub mod factory;
pub mod layers;
pub mod manager;
mod metrics;
pub mod secure_fs;
pub mod test_util;
pub mod util;
+682
View File
@@ -0,0 +1,682 @@
// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! A capability-based filesystem backend for untrusted object paths.
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::vec::IntoIter;
use std::{fmt, io};
use cap_std::ambient_authority;
use cap_std::fs::{Dir, DirEntry, OpenOptions, ReadDir};
use opendal::raw::*;
use opendal::{
Buffer, Capability, EntryMode, Error, ErrorKind, Metadata, Operator, OperatorBuilder, Result,
};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
const LIST_BATCH_SIZE: usize = 128;
/// An opened filesystem root that confines all descendant path resolution.
#[derive(Clone)]
pub struct SecureFsRoot {
dir: Arc<Dir>,
path: Arc<PathBuf>,
}
impl fmt::Debug for SecureFsRoot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecureFsRoot")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl SecureFsRoot {
/// Creates and opens `path` using ambient authority.
///
/// Callers must only pass a server-controlled path.
pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
let path = path.as_ref();
std::fs::create_dir_all(path)?;
if std::fs::symlink_metadata(path)?.file_type().is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"filesystem sandbox root must not be a symbolic link",
));
}
let path = path.canonicalize()?;
let dir = Dir::open_ambient_dir(&path, ambient_authority())?;
Ok(Self {
dir: Arc::new(dir),
path: Arc::new(path),
})
}
/// Returns the canonical path used to open this root.
pub fn path(&self) -> &Path {
&self.path
}
/// Opens a descendant directory without leaving this capability root.
pub fn open_subdir(&self, path: impl AsRef<Path>) -> io::Result<Self> {
let path = normalize_relative_path(path.as_ref())?;
if path.as_os_str().is_empty() {
return Ok(self.clone());
}
let dir = self.dir.open_dir(&path)?;
Ok(Self {
dir: Arc::new(dir),
path: Arc::new(self.path.join(path)),
})
}
/// Creates and opens a descendant directory without leaving this capability root.
pub fn create_subdir(&self, path: impl AsRef<Path>) -> io::Result<Self> {
let path = normalize_relative_path(path.as_ref())?;
if path.as_os_str().is_empty() {
return Ok(self.clone());
}
self.dir.create_dir_all(&path)?;
let dir = self.dir.open_dir(&path)?;
Ok(Self {
dir: Arc::new(dir),
path: Arc::new(self.path.join(path)),
})
}
/// Builds an OpenDAL operator confined to this root.
pub fn build_operator(&self) -> Operator {
OperatorBuilder::new(SecureFsBackend::new(self.clone())).finish()
}
}
fn normalize_relative_path(path: &Path) -> io::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"path escapes the filesystem sandbox",
));
}
}
}
Ok(normalized)
}
fn backend_path(path: &str) -> io::Result<PathBuf> {
let path = path.trim_matches('/');
if path.is_empty() {
Ok(PathBuf::new())
} else {
normalize_relative_path(Path::new(path))
}
}
fn parse_write_error(error: io::Error, if_not_exists: bool) -> Error {
if if_not_exists && error.kind() == io::ErrorKind::AlreadyExists {
Error::new(
ErrorKind::ConditionNotMatch,
"the file already exists in the filesystem",
)
.set_source(error)
} else {
new_std_io_error(error)
}
}
fn metadata_from_fs(metadata: cap_std::fs::Metadata) -> Result<Metadata> {
let mode = if metadata.is_dir() {
EntryMode::DIR
} else if metadata.is_file() {
EntryMode::FILE
} else {
EntryMode::Unknown
};
Ok(Metadata::new(mode)
.with_content_length(metadata.len())
.with_last_modified(Timestamp::try_from(
metadata.modified().map_err(new_std_io_error)?.into_std(),
)?))
}
#[derive(Clone, Debug)]
struct SecureFsBackend {
root: SecureFsRoot,
info: Arc<AccessorInfo>,
}
impl SecureFsBackend {
fn new(root: SecureFsRoot) -> Self {
let info = AccessorInfo::default();
info.set_scheme("fs")
.set_root(&root.path().to_string_lossy())
.set_native_capability(Capability {
stat: true,
read: true,
write: true,
write_can_empty: true,
write_can_append: true,
write_can_multi: true,
write_with_if_not_exists: true,
create_dir: true,
delete: true,
delete_with_recursive: true,
list: true,
shared: true,
..Default::default()
});
Self {
root,
info: info.into(),
}
}
}
impl Access for SecureFsBackend {
type Reader = SecureFsReader;
type Writer = SecureFsWriter;
type Lister = Option<SecureFsLister>;
type Deleter = oio::OneShotDeleter<SecureFsDeleter>;
type Copier = ();
fn info(&self) -> Arc<AccessorInfo> {
self.info.clone()
}
async fn create_dir(&self, path: &str, _: OpCreateDir) -> Result<RpCreateDir> {
let path = backend_path(path).map_err(new_std_io_error)?;
let root = self.root.clone();
common_runtime::spawn_blocking_global(move || root.dir.create_dir_all(path))
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)?;
Ok(RpCreateDir::default())
}
async fn stat(&self, path: &str, _: OpStat) -> Result<RpStat> {
let path = backend_path(path).map_err(new_std_io_error)?;
let root = self.root.clone();
let metadata = common_runtime::spawn_blocking_global(move || {
if path.as_os_str().is_empty() {
root.dir.dir_metadata()
} else {
root.dir.metadata(path)
}
})
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)?;
Ok(RpStat::new(metadata_from_fs(metadata)?))
}
async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> {
let path = backend_path(path).map_err(new_std_io_error)?;
let root = self.root.clone();
let file = common_runtime::spawn_blocking_global(move || root.dir.open(path))
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)?;
let mut file = tokio::fs::File::from_std(file.into_std());
if args.range().offset() != 0 {
file.seek(io::SeekFrom::Start(args.range().offset()))
.await
.map_err(new_std_io_error)?;
}
Ok((
RpRead::default(),
SecureFsReader {
file,
remaining: args.range().size().unwrap_or(u64::MAX),
},
))
}
async fn write(&self, path: &str, args: OpWrite) -> Result<(RpWrite, Self::Writer)> {
let path = backend_path(path).map_err(new_std_io_error)?;
let root = self.root.clone();
let if_not_exists = args.if_not_exists();
let file = common_runtime::spawn_blocking_global(move || {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
root.dir.create_dir_all(parent).map_err(new_std_io_error)?;
}
let mut options = OpenOptions::new();
options.write(true);
if args.if_not_exists() {
options.create_new(true);
} else {
options.create(true);
}
if args.append() {
options.append(true);
} else {
options.truncate(true);
}
root.dir
.open_with(path, &options)
.map_err(|error| parse_write_error(error, if_not_exists))
})
.await
.map_err(new_task_join_error)??;
Ok((
RpWrite::default(),
SecureFsWriter {
file: tokio::fs::File::from_std(file.into_std()),
},
))
}
async fn delete(&self) -> Result<(RpDelete, Self::Deleter)> {
Ok((
RpDelete::default(),
oio::OneShotDeleter::new(SecureFsDeleter {
root: self.root.clone(),
}),
))
}
async fn list(&self, path: &str, _: OpList) -> Result<(RpList, Self::Lister)> {
let path = backend_path(path).map_err(new_std_io_error)?;
let display_prefix = if path.as_os_str().is_empty() {
String::new()
} else {
format!("{}/", path.to_string_lossy().replace('\\', "/"))
};
let root = self.root.clone();
let read_dir = common_runtime::spawn_blocking_global(move || {
let result = (|| {
let dir = if path.as_os_str().is_empty() {
root.dir.open_dir(".")?
} else {
root.dir.open_dir(&path)?
};
dir.entries()
})();
match result {
Ok(read_dir) => Ok(Some(read_dir)),
Err(error)
if matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
Ok(None)
}
Err(error) => Err(error),
}
})
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)?;
let Some(read_dir) = read_dir else {
return Ok((RpList::default(), None));
};
let current_path = oio::Entry::new(
if display_prefix.is_empty() {
"/"
} else {
&display_prefix
},
Metadata::new(EntryMode::DIR),
);
Ok((
RpList::default(),
Some(SecureFsLister {
read_dir: Arc::new(Mutex::new(read_dir)),
display_prefix,
entries: vec![current_path].into_iter(),
done: false,
}),
))
}
}
struct SecureFsReader {
file: tokio::fs::File,
remaining: u64,
}
impl oio::Read for SecureFsReader {
async fn read(&mut self) -> Result<Buffer> {
if self.remaining == 0 {
return Ok(Buffer::new());
}
let size = self.remaining.min(2 * 1024 * 1024) as usize;
let mut buffer = vec![0; size];
let read = self
.file
.read(&mut buffer)
.await
.map_err(new_std_io_error)?;
self.remaining = self.remaining.saturating_sub(read as u64);
buffer.truncate(read);
Ok(Buffer::from(buffer))
}
}
struct SecureFsWriter {
file: tokio::fs::File,
}
impl oio::Write for SecureFsWriter {
async fn write(&mut self, buffer: Buffer) -> Result<()> {
self.file
.write_all(&buffer.to_bytes())
.await
.map_err(new_std_io_error)
}
async fn close(&mut self) -> Result<Metadata> {
self.file.flush().await.map_err(new_std_io_error)?;
self.file.sync_all().await.map_err(new_std_io_error)?;
let metadata = self.file.metadata().await.map_err(new_std_io_error)?;
Ok(Metadata::new(EntryMode::FILE)
.with_content_length(metadata.len())
.with_last_modified(Timestamp::try_from(
metadata.modified().map_err(new_std_io_error)?,
)?))
}
async fn abort(&mut self) -> Result<()> {
Err(Error::new(
ErrorKind::Unsupported,
"filesystem writes cannot be aborted without atomic writes",
))
}
}
struct SecureFsLister {
read_dir: Arc<Mutex<ReadDir>>,
display_prefix: String,
entries: IntoIter<oio::Entry>,
done: bool,
}
impl oio::List for SecureFsLister {
async fn next(&mut self) -> Result<Option<oio::Entry>> {
if let Some(entry) = self.entries.next() {
return Ok(Some(entry));
}
if self.done {
return Ok(None);
}
let read_dir = self.read_dir.clone();
let display_prefix = self.display_prefix.clone();
let (entries, done) = common_runtime::spawn_blocking_global(move || {
let mut read_dir = read_dir
.lock()
.map_err(|_| io::Error::other("filesystem directory iterator lock is poisoned"))?;
read_list_batch(&mut read_dir, &display_prefix)
})
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)?;
self.entries = entries.into_iter();
self.done = done;
Ok(self.entries.next())
}
}
fn read_list_batch(
read_dir: &mut ReadDir,
display_prefix: &str,
) -> io::Result<(Vec<oio::Entry>, bool)> {
let mut entries = Vec::with_capacity(LIST_BATCH_SIZE);
while entries.len() < LIST_BATCH_SIZE {
let entry = match read_dir.next() {
Some(Ok(entry)) => entry,
Some(Err(error)) if error.kind() == io::ErrorKind::NotFound => {
return Ok((entries, true));
}
Some(Err(error)) => return Err(error),
None => return Ok((entries, true)),
};
if let Some(entry) = read_list_entry(entry, display_prefix)? {
entries.push(entry);
}
}
Ok((entries, false))
}
fn read_list_entry(entry: DirEntry, display_prefix: &str) -> io::Result<Option<oio::Entry>> {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
let name = entry.file_name().to_string_lossy().to_string();
let (path, mode) = if file_type.is_dir() {
(format!("{display_prefix}{name}/"), EntryMode::DIR)
} else if file_type.is_file() {
(format!("{display_prefix}{name}"), EntryMode::FILE)
} else {
(format!("{display_prefix}{name}"), EntryMode::Unknown)
};
let metadata = if mode == EntryMode::Unknown {
Metadata::new(mode)
} else {
match entry.metadata() {
Ok(metadata) => match metadata_from_fs(metadata) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io::Error::other(error.to_string())),
},
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
}
};
Ok(Some(oio::Entry::new(&path, metadata)))
}
struct SecureFsDeleter {
root: SecureFsRoot,
}
impl oio::OneShotDelete for SecureFsDeleter {
async fn delete_once(&self, path: String, args: OpDelete) -> Result<()> {
let path = backend_path(&path).map_err(new_std_io_error)?;
if path.as_os_str().is_empty() {
return Err(Error::new(
ErrorKind::Unsupported,
"deleting the filesystem sandbox root is not supported",
));
}
let root = self.root.clone();
common_runtime::spawn_blocking_global(move || {
let metadata = match root.dir.symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
if metadata.is_dir() {
if args.recursive() {
root.dir.remove_dir_all(path)
} else {
root.dir.remove_dir(path)
}
} else {
root.dir.remove_file(path)
}
})
.await
.map_err(new_task_join_error)?
.map_err(new_std_io_error)
}
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use common_test_util::temp_dir::create_temp_dir;
use opendal::ErrorKind;
use opendal::raw::oio::List;
use opendal::raw::{Access, OpList};
use super::{LIST_BATCH_SIZE, SecureFsBackend, SecureFsRoot, read_list_entry};
#[tokio::test]
async fn test_lister_streams_entries() {
let temp_dir = create_temp_dir("secure_fs_lister_streams_entries");
for index in 0..129 {
std::fs::write(temp_dir.path().join(format!("{index}.parquet")), []).unwrap();
}
let root = SecureFsRoot::open(temp_dir.path()).unwrap();
let backend = SecureFsBackend::new(root);
let (_, lister) = backend.list("/", OpList::new()).await.unwrap();
let mut lister = lister.unwrap();
assert_eq!(1, lister.entries.len());
let mut paths = Vec::new();
while let Some(entry) = lister.next().await.unwrap() {
paths.push(entry.path().to_string());
assert!(lister.entries.len() <= LIST_BATCH_SIZE);
}
assert_eq!(130, paths.len());
assert!(paths.iter().any(|path| path == "/"));
assert!(paths.iter().any(|path| path == "128.parquet"));
}
#[tokio::test]
async fn test_if_not_exists_returns_condition_not_match() {
let temp_dir = create_temp_dir("secure_fs_if_not_exists");
let operator = SecureFsRoot::open(temp_dir.path())
.unwrap()
.build_operator();
operator
.write("existing", Bytes::from_static(b"original"))
.await
.unwrap();
let error = operator
.write_with("existing", Bytes::from_static(b"replacement"))
.if_not_exists(true)
.await
.unwrap_err();
assert_eq!(ErrorKind::ConditionNotMatch, error.kind());
assert_eq!(
Bytes::from_static(b"original"),
operator.read("existing").await.unwrap().to_bytes()
);
}
#[tokio::test]
async fn test_if_not_exists_does_not_remap_parent_directory_error() {
let temp_dir = create_temp_dir("secure_fs_if_not_exists_parent_error");
std::fs::write(temp_dir.path().join("parent"), []).unwrap();
let operator = SecureFsRoot::open(temp_dir.path())
.unwrap()
.build_operator();
let error = operator
.write_with("parent/file", Bytes::new())
.if_not_exists(true)
.await
.unwrap_err();
assert_eq!(ErrorKind::AlreadyExists, error.kind());
}
#[tokio::test]
async fn test_list_missing_or_non_directory_is_empty() {
let temp_dir = create_temp_dir("secure_fs_list_missing_or_non_directory");
std::fs::write(temp_dir.path().join("file"), []).unwrap();
let operator = SecureFsRoot::open(temp_dir.path())
.unwrap()
.build_operator();
assert!(operator.list("missing/").await.unwrap().is_empty());
assert!(operator.list("file/").await.unwrap().is_empty());
}
#[test]
fn test_lister_skips_entry_removed_during_iteration() {
let temp_dir = create_temp_dir("secure_fs_lister_removed_entry");
let path = temp_dir.path().join("removed");
std::fs::write(&path, []).unwrap();
let root = SecureFsRoot::open(temp_dir.path()).unwrap();
let mut read_dir = root.dir.entries().unwrap();
let entry = read_dir.next().unwrap().unwrap();
std::fs::remove_file(path).unwrap();
assert!(read_list_entry(entry, "").unwrap().is_none());
}
#[tokio::test]
async fn test_delete_root_is_unsupported() {
let temp_dir = create_temp_dir("secure_fs_delete_root");
let operator = SecureFsRoot::open(temp_dir.path())
.unwrap()
.build_operator();
operator
.write("nested/file", Bytes::from_static(b"data"))
.await
.unwrap();
let error = operator.delete_with("/").recursive(true).await.unwrap_err();
assert_eq!(ErrorKind::Unsupported, error.kind());
assert!(temp_dir.path().join("nested/file").exists());
operator
.delete_with("nested/")
.recursive(true)
.await
.unwrap();
assert!(!temp_dir.path().join("nested").exists());
}
#[tokio::test]
async fn test_writer_abort_is_unsupported_without_atomic_write() {
let temp_dir = create_temp_dir("secure_fs_writer_abort");
std::fs::write(temp_dir.path().join("partial"), b"original").unwrap();
let operator = SecureFsRoot::open(temp_dir.path())
.unwrap()
.build_operator();
let mut writer = operator.writer("partial").await.unwrap();
writer.write(Bytes::from_static(b"partial")).await.unwrap();
let error = writer.abort().await.unwrap_err();
assert_eq!(ErrorKind::Unsupported, error.kind());
assert_eq!(
b"partial",
std::fs::read(temp_dir.path().join("partial"))
.unwrap()
.as_slice()
);
}
}
+3 -19
View File
@@ -469,13 +469,6 @@ pub enum Error {
source: table::error::Error,
},
#[snafu(display("Failed to parse data source url"))]
ParseUrl {
#[snafu(implicit)]
location: Location,
source: common_datasource::error::Error,
},
#[snafu(display("Unsupported format: {:?}", format))]
UnsupportedFormat {
#[snafu(implicit)]
@@ -852,13 +845,6 @@ pub enum Error {
location: Location,
},
#[snafu(display("Path not found: {path}"))]
PathNotFound {
path: String,
#[snafu(implicit)]
location: Location,
},
#[snafu(display("Invalid time index type: {}", ty))]
InvalidTimeIndexType {
ty: arrow::datatypes::DataType,
@@ -1040,9 +1026,9 @@ impl ErrorExt for Error {
Error::ReadObject { .. }
| Error::ReadParquetMetadata { .. }
| Error::ReadOrc { .. } => StatusCode::StorageUnavailable,
Error::ListObjects { source, .. }
| Error::ParseUrl { source, .. }
| Error::BuildBackend { source, .. } => source.status_code(),
Error::ListObjects { source, .. } | Error::BuildBackend { source, .. } => {
source.status_code()
}
Error::ExecuteDdl { source, .. } => source.status_code(),
Error::InvalidCopyParameter { .. } | Error::InvalidCopyDatabasePath { .. } => {
StatusCode::InvalidArguments
@@ -1063,7 +1049,6 @@ impl ErrorExt for Error {
}
Error::InvalidProcessId { .. } => StatusCode::InvalidArguments,
Error::ProcessManagerMissing { .. } => StatusCode::Unexpected,
Error::PathNotFound { .. } => StatusCode::InvalidArguments,
Error::TimestampFormatNotSupported { .. } => StatusCode::InvalidArguments,
Error::SqlCommon { source, .. } => source.status_code(),
#[cfg(feature = "enterprise")]
@@ -1096,7 +1081,6 @@ impl ErrorExt for Error {
Error::ParseFileFormat { source, .. }
| Error::InferSchema { source, .. }
| Error::ListObjects { source, .. }
| Error::ParseUrl { source, .. }
| Error::BuildBackend { source, .. }
| Error::ReadOrc { source, .. } => source.retry_hint(),
+3 -1
View File
@@ -31,6 +31,7 @@ use api::v1::{
UnsetIndex, UnsetIndexes, UnsetInverted, UnsetSkipping, UnsetTableOptions, set_index,
unset_index,
};
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::BoxedError;
use common_grpc_expr::util::ColumnExpr;
use common_time::Timezone;
@@ -140,6 +141,7 @@ pub fn extract_add_columns_expr(
pub(crate) async fn create_external_expr(
create: CreateExternalTable,
query_ctx: &QueryContextRef,
local_file_access: &LocalFileAccess,
) -> Result<CreateTableExpr> {
let (catalog_name, schema_name, table_name) =
table_idents_to_full_name(&create.name, query_ctx)
@@ -148,7 +150,7 @@ pub(crate) async fn create_external_expr(
let mut table_options = create.options.into_map();
let (object_store, files) = prepare_file_table_files(&table_options)
let (object_store, files) = prepare_file_table_files(&table_options, local_file_access)
.await
.context(PrepareFileTableSnafu)?;
+4
View File
@@ -37,6 +37,7 @@ use catalog::process_manager::ProcessManagerRef;
use client::RecordBatches;
use client::error::{ExternalSnafu as ClientExternalSnafu, Result as ClientResult};
use client::inserter::{InsertOptions, Inserter};
use common_datasource::object_store::LocalFileAccess;
use common_error::ext::BoxedError;
use common_meta::cache_invalidator::CacheInvalidatorRef;
use common_meta::key::flow::{FlowMetadataManager, FlowMetadataManagerRef};
@@ -137,6 +138,7 @@ pub struct StatementExecutor {
inserter: InserterRef,
process_manager: Option<ProcessManagerRef>,
origin_frontend_addr: String,
pub(crate) local_file_access: LocalFileAccess,
#[cfg(feature = "enterprise")]
create_database_handler: Option<CreateDatabaseHandlerRef>,
#[cfg(feature = "enterprise")]
@@ -175,6 +177,7 @@ impl StatementExecutor {
partition_manager: PartitionRuleManagerRef,
process_manager: Option<ProcessManagerRef>,
origin_frontend_addr: String,
local_file_access: LocalFileAccess,
) -> Self {
Self {
catalog_manager,
@@ -188,6 +191,7 @@ impl StatementExecutor {
inserter,
process_manager,
origin_frontend_addr,
local_file_access,
#[cfg(feature = "enterprise")]
create_database_handler: None,
#[cfg(feature = "enterprise")]
+45 -11
View File
@@ -21,7 +21,9 @@ use client::{Output, OutputData, OutputMeta};
use common_catalog::format_full_table_name;
use common_datasource::file_format::Format;
use common_datasource::lister::{Lister, Source};
use common_datasource::object_store::build_backend;
#[cfg(windows)]
use common_datasource::object_store::{FS_SCHEMA, parse_url};
use common_datasource::object_store::{LocalFileAccess, build_backend, build_backend_for_write};
use common_stat::get_total_cpu_cores;
use common_telemetry::{debug, error, info, tracing};
use futures::future::try_join_all;
@@ -43,6 +45,24 @@ pub(crate) const COPY_DATABASE_TIME_END_KEY: &str = "end_time";
pub(crate) const CONTINUE_ON_ERROR_KEY: &str = "continue_on_error";
pub(crate) const PARALLELISM_KEY: &str = "parallelism";
fn is_directory_location(location: &str) -> bool {
if location.ends_with('/') {
return true;
}
#[cfg(windows)]
{
location.ends_with(std::path::MAIN_SEPARATOR)
&& matches!(
parse_url(location),
Ok((schema, _, _)) if schema.eq_ignore_ascii_case(FS_SCHEMA)
)
}
#[cfg(not(windows))]
false
}
/// Get parallelism from options, default to total CPU cores.
fn parse_parallelism_from_option_map(options: &HashMap<String, String>) -> usize {
options
@@ -59,13 +79,16 @@ impl StatementExecutor {
req: CopyDatabaseRequest,
ctx: QueryContextRef,
) -> error::Result<Output> {
// location must end with / so that every table is exported to a file.
// Location must end with a separator so that every table is exported to a file.
ensure!(
req.location.ends_with('/'),
is_directory_location(&req.location),
InvalidCopyDatabasePathSnafu {
value: req.location,
}
);
build_backend_for_write(&req.location, &req.connection, &self.local_file_access)
.await
.context(error::BuildBackendSnafu)?;
let parallelism = parse_parallelism_from_option_map(&req.with);
info!(
@@ -153,9 +176,9 @@ impl StatementExecutor {
req: CopyDatabaseRequest,
ctx: QueryContextRef,
) -> error::Result<Output> {
// location must end with /
// Location must end with a directory separator.
ensure!(
req.location.ends_with('/'),
is_directory_location(&req.location),
InvalidCopyDatabasePathSnafu {
value: req.location,
}
@@ -170,7 +193,7 @@ impl StatementExecutor {
.context(error::ParseFileFormatSnafu)?
.suffix();
let entries = list_files_to_copy(&req, suffix).await?;
let entries = list_files_to_copy(&req, suffix, &self.local_file_access).await?;
let continue_on_error = req
.with
@@ -198,7 +221,7 @@ impl StatementExecutor {
catalog_name: req.catalog_name.clone(),
schema_name: req.schema_name.clone(),
table_name: table_name.clone(),
location: format!("{}/{}", req.location, e.path()),
location: format!("{}{}", req.location, e.path()),
with: req.with.clone(),
connection: req.connection.clone(),
pattern: None,
@@ -255,9 +278,14 @@ fn parse_file_name_to_copy(e: &Entry) -> error::Result<String> {
}
/// Lists all files with expected suffix that can be imported to database.
async fn list_files_to_copy(req: &CopyDatabaseRequest, suffix: &str) -> error::Result<Vec<Entry>> {
let object_store =
build_backend(&req.location, &req.connection).context(error::BuildBackendSnafu)?;
async fn list_files_to_copy(
req: &CopyDatabaseRequest,
suffix: &str,
local_file_access: &LocalFileAccess,
) -> error::Result<Vec<Entry>> {
let object_store = build_backend(&req.location, &req.connection, local_file_access)
.await
.context(error::BuildBackendSnafu)?;
let pattern = Regex::try_from(format!(".*{}", suffix)).context(error::BuildRegexSnafu)?;
let lister = Lister::new(
@@ -273,10 +301,12 @@ async fn list_files_to_copy(req: &CopyDatabaseRequest, suffix: &str) -> error::R
mod tests {
use std::collections::{HashMap, HashSet};
use common_datasource::object_store::LocalFileAccess;
use common_stat::get_total_cpu_cores;
use object_store::ObjectStore;
use object_store::services::Fs;
use object_store::util::normalize_dir;
#[cfg(not(windows))]
use path_slash::PathExt;
use table::requests::CopyDatabaseRequest;
@@ -296,7 +326,10 @@ mod tests {
object_store.write("d", "").await.unwrap();
object_store.write("e.f.parquet", "").await.unwrap();
#[cfg(not(windows))]
let location = normalize_dir(&dir.path().to_slash().unwrap());
#[cfg(windows)]
let location = format!("{}\\", dir.path().display());
let request = CopyDatabaseRequest {
catalog_name: "catalog_0".to_string(),
schema_name: "schema_0".to_string(),
@@ -307,7 +340,8 @@ mod tests {
connection: Default::default(),
time_range: None,
};
let listed = list_files_to_copy(&request, ".parquet")
let local_file_access = LocalFileAccess::sandboxed(dir.path()).unwrap();
let listed = list_files_to_copy(&request, ".parquet", &local_file_access)
.await
.unwrap()
.into_iter()
+18 -18
View File
@@ -14,7 +14,6 @@
use std::collections::{BTreeSet, HashMap, HashSet};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
@@ -28,8 +27,7 @@ use common_datasource::file_format::json::JsonFormat;
use common_datasource::file_format::orc::{ReaderAdapter, infer_orc_schema, new_orc_stream_reader};
use common_datasource::file_format::{FileFormat, Format, file_to_stream};
use common_datasource::lister::{Lister, Source};
use common_datasource::object_store::{FS_SCHEMA, build_backend, parse_url};
use common_datasource::util::find_dir_and_filename;
use common_datasource::object_store::build_backend_with_path;
use common_query::{OutputCost, OutputRows};
use common_recordbatch::DfSendableRecordBatchStream;
use common_recordbatch::adapter::RecordBatchStreamTypeAdapter;
@@ -54,7 +52,7 @@ use table::requests::{CopyTableRequest, InsertRequest};
use table::table_reference::TableReference;
use tokio_util::compat::FuturesAsyncReadCompatExt;
use crate::error::{self, IntoVectorsSnafu, PathNotFoundSnafu, Result};
use crate::error::{self, IntoVectorsSnafu, Result};
use crate::statement::StatementExecutor;
const DEFAULT_BATCH_SIZE: usize = 8192;
@@ -99,16 +97,10 @@ impl StatementExecutor {
&self,
req: &CopyTableRequest,
) -> Result<(ObjectStore, Vec<Entry>)> {
let (schema, _host, path) = parse_url(&req.location).context(error::ParseUrlSnafu)?;
if schema.to_uppercase() == FS_SCHEMA {
ensure!(Path::new(&path).exists(), PathNotFoundSnafu { path });
}
let object_store =
build_backend(&req.location, &req.connection).context(error::BuildBackendSnafu)?;
let (dir, filename) = find_dir_and_filename(&path);
let backend =
build_backend_with_path(&req.location, &req.connection, &self.local_file_access)
.await
.context(error::BuildBackendSnafu)?;
let regex = req
.pattern
.as_ref()
@@ -116,17 +108,25 @@ impl StatementExecutor {
.transpose()
.context(error::BuildRegexSnafu)?;
let source = if let Some(filename) = filename {
let source = if let Some(filename) = backend.object_path {
Source::Filename(filename)
} else {
Source::Dir
};
let lister = Lister::new(object_store.clone(), source.clone(), dir.clone(), regex);
let lister = Lister::new(
backend.object_store.clone(),
source.clone(),
req.location.clone(),
regex,
);
let entries = lister.list().await.context(error::ListObjectsSnafu)?;
debug!("Copy from dir: {dir:?}, {source:?}, entries: {entries:?}");
Ok((object_store, entries))
debug!(
"Copy from location: {:?}, {source:?}, entries: {entries:?}",
req.location
);
Ok((backend.object_store, entries))
}
async fn collect_metadata(
+8 -8
View File
@@ -21,8 +21,7 @@ use common_datasource::file_format::Format;
use common_datasource::file_format::csv::stream_to_csv;
use common_datasource::file_format::json::stream_to_json;
use common_datasource::file_format::parquet::stream_to_parquet;
use common_datasource::object_store::{build_backend, parse_url};
use common_datasource::util::find_dir_and_filename;
use common_datasource::object_store::build_backend_for_write_with_path;
use common_query::Output;
use common_recordbatch::adapter::DfRecordBatchStreamAdapter;
use common_recordbatch::{
@@ -178,13 +177,14 @@ impl StatementExecutor {
_ => unreachable!(),
};
let (_schema, _host, path) = parse_url(location).context(error::ParseUrlSnafu)?;
let (_, filename) = find_dir_and_filename(&path);
let filename = filename.context(error::UnexpectedSnafu {
violated: format!("Expected filename, path: {path}"),
let backend =
build_backend_for_write_with_path(location, connection, &self.local_file_access)
.await
.context(error::BuildBackendSnafu)?;
let filename = backend.object_path.context(error::UnexpectedSnafu {
violated: format!("Expected filename, path: {location}"),
})?;
let object_store = build_backend(location, connection).context(error::BuildBackendSnafu)?;
self.stream_to_file(stream, format, object_store, &filename)
self.stream_to_file(stream, format, backend.object_store, &filename)
.await
}
}
+3 -1
View File
@@ -361,7 +361,9 @@ impl StatementExecutor {
create_expr: CreateExternalTable,
ctx: QueryContextRef,
) -> Result<TableRef> {
let create_expr = &mut expr_helper::create_external_expr(create_expr, &ctx).await?;
let create_expr =
&mut expr_helper::create_external_expr(create_expr, &ctx, &self.local_file_access)
.await?;
self.create_table_inner(create_expr, None, ctx).await
}
+22 -1
View File
@@ -424,7 +424,8 @@ impl ErrorExt for Error {
| InvalidQueryContextExtension { .. }
| ConflictingSnapshotSequence { .. } => StatusCode::InvalidArguments,
BuildBackend { .. } | ListObjects { .. } => StatusCode::StorageUnavailable,
BuildBackend { source, .. } => source.status_code(),
ListObjects { .. } => StatusCode::StorageUnavailable,
TableNotFound { .. } => StatusCode::TableNotFound,
@@ -494,3 +495,23 @@ impl From<Error> for DataFusionError {
DataFusionError::External(Box::new(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_backend_delegates_error_metadata() {
let source = common_datasource::error::LocalFileAccessDisabledSnafu {
path: "file:///tmp/data.parquet",
}
.build();
let error = Error::BuildBackend {
source,
location: Location::default(),
};
assert_eq!(error.status_code(), StatusCode::InvalidArguments);
assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
}
}
+12 -11
View File
@@ -30,8 +30,7 @@ use common_catalog::consts::{
use common_catalog::format_full_table_name;
use common_datasource::file_format::{FileFormat, Format, infer_schemas};
use common_datasource::lister::{Lister, Source};
use common_datasource::object_store::build_backend;
use common_datasource::util::find_dir_and_filename;
use common_datasource::object_store::{LocalFileAccess, build_backend_with_path};
use common_meta::SchemaOptions;
use common_meta::ddl::create_flow::FlowType;
use common_meta::key::flow::flow_info::FlowInfoValue;
@@ -1149,6 +1148,7 @@ fn describe_column_semantic_types(
// lists files in the frontend to reduce unnecessary scan requests repeated in each datanode.
pub async fn prepare_file_table_files(
options: &HashMap<String, String>,
local_file_access: &LocalFileAccess,
) -> Result<(ObjectStore, Vec<String>)> {
let url = options
.get(FILE_TABLE_LOCATION_KEY)
@@ -1156,19 +1156,20 @@ pub async fn prepare_file_table_files(
name: FILE_TABLE_LOCATION_KEY,
})?;
let (dir, filename) = find_dir_and_filename(url);
let source = if let Some(filename) = filename {
Source::Filename(filename)
} else {
Source::Dir
};
let regex = options
.get(FILE_TABLE_PATTERN_KEY)
.map(|x| Regex::new(x))
.transpose()
.context(error::BuildRegexSnafu)?;
let object_store = build_backend(url, options).context(error::BuildBackendSnafu)?;
let lister = Lister::new(object_store.clone(), source, dir, regex);
let backend = build_backend_with_path(url, options, local_file_access)
.await
.context(error::BuildBackendSnafu)?;
let source = if let Some(filename) = backend.object_path {
Source::Filename(filename)
} else {
Source::Dir
};
let lister = Lister::new(backend.object_store.clone(), source, url.clone(), regex);
// If we scan files in a directory every time the database restarts,
// then it might lead to a potential undefined behavior:
// If a user adds a file with an incompatible schema to that directory,
@@ -1186,7 +1187,7 @@ pub async fn prepare_file_table_files(
}
})
.collect::<Vec<_>>();
Ok((object_store, files))
Ok((backend.object_store, files))
}
pub async fn infer_file_table_schema(
+1
View File
@@ -33,6 +33,7 @@ cmd.workspace = true
common-base.workspace = true
common-catalog.workspace = true
common-config.workspace = true
common-datasource.workspace = true
common-error.workspace = true
common-event-recorder.workspace = true
common-frontend.workspace = true
+5
View File
@@ -26,6 +26,7 @@ use cmd::error::StartFlownodeSnafu;
use common_base::Plugins;
use common_catalog::consts::{MIN_USER_FLOW_ID, MIN_USER_TABLE_ID};
use common_config::KvBackendConfig;
use common_datasource::object_store::LocalFileAccess;
use common_meta::cache::LayeredCacheRegistryBuilder;
use common_meta::ddl::flow_meta::FlowMetadataAllocator;
use common_meta::ddl::table_meta::TableMetadataAllocator;
@@ -43,6 +44,7 @@ use common_procedure::ProcedureManagerRef;
use common_procedure::local::EventRecorderHandle;
use common_procedure::options::ProcedureConfig;
use common_telemetry::logging::SlowQueryOptions;
use common_test_util::find_workspace_path;
use common_wal::config::{DatanodeWalConfig, MetasrvWalConfig};
use datanode::datanode::DatanodeBuilder;
use flow::{FlownodeBuilder, FrontendClient, GrpcQueryHandlerWithBoxedError};
@@ -172,7 +174,9 @@ impl GreptimeDbStandaloneBuilder {
let mut builder =
DatanodeBuilder::new(opts.datanode_options(), plugins.clone(), kv_backend.clone());
let local_file_access = LocalFileAccess::sandboxed(find_workspace_path(".")).unwrap();
builder.with_cache_registry(layered_cache_registry);
builder.with_local_file_access(local_file_access.clone());
let datanode = builder.build().await.unwrap();
let table_metadata_manager = Arc::new(TableMetadataManager::new(kv_backend.clone()));
@@ -278,6 +282,7 @@ impl GreptimeDbStandaloneBuilder {
procedure_executor.clone(),
Arc::new(ProcessManager::new(server_addr, None)),
)
.with_local_file_access(local_file_access)
.with_plugin(plugins.clone())
.try_build()
.await
+1
View File
@@ -362,6 +362,7 @@ pub(crate) fn create_datanode_opts(
require_lease_before_startup: true,
storage: StorageConfig {
data_home: home_dir,
copy_root: None,
providers,
store: default_store,
},
+73 -32
View File
@@ -17,11 +17,11 @@ use std::sync::Arc;
use client::{DEFAULT_SCHEMA_NAME, OutputData};
use common_catalog::consts::DEFAULT_CATALOG_NAME;
use common_error::ext::ErrorExt;
use common_error::ext::{ErrorExt, RetryHint};
use common_error::status_code::StatusCode;
use common_query::Output;
use common_recordbatch::util;
use common_test_util::recordbatch::check_output_stream;
use common_test_util::temp_dir;
use datatypes::arrow::array::{
ArrayRef, AsArray, StringArray, TimestampMillisecondArray, UInt64Array,
};
@@ -37,9 +37,9 @@ use session::context::{QueryContext, QueryContextRef};
use crate::tests::test_util::{
MockInstance, both_instances_cases, both_instances_cases_with_custom_storages,
check_unordered_output_stream, distributed, distributed_with_multiple_object_stores,
find_testing_resource, prepare_path, standalone, standalone_instance_case,
standalone_with_multiple_object_stores,
check_unordered_output_stream, create_local_file_test_dir, distributed,
distributed_with_multiple_object_stores, find_testing_resource, prepare_path, standalone,
standalone_instance_case, standalone_with_multiple_object_stores,
};
#[apply(both_instances_cases)]
@@ -250,7 +250,7 @@ PARTITION ON COLUMNS (n) (
check_output_stream(output, expected).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_extra_external_table_options(instance: Arc<dyn MockInstance>) {
let frontend = instance.frontend();
let format = "json";
@@ -277,7 +277,7 @@ async fn test_extra_external_table_options(instance: Arc<dyn MockInstance>) {
));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_show_create_external_table(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -661,11 +661,52 @@ async fn test_execute_create(instance: Arc<dyn MockInstance>) {
assert!(matches!(output, OutputData::AffectedRows(0)));
}
#[apply(both_instances_cases)]
#[tokio::test(flavor = "multi_thread")]
async fn test_distributed_local_file_access_disabled() {
let instance = distributed().await.frontend();
execute_sql(
&instance,
"CREATE TABLE local_file_access_distributed (
ts TIMESTAMP TIME INDEX,
host STRING PRIMARY KEY,
val DOUBLE
);",
)
.await;
let statements = [
"COPY local_file_access_distributed TO 'local_file_access/table.parquet';",
"COPY local_file_access_distributed FROM 'local_file_access/table.parquet';",
"COPY (SELECT * FROM local_file_access_distributed) TO 'local_file_access/query.parquet';",
"COPY DATABASE public TO 'local_file_access/database/';",
"COPY DATABASE public FROM 'local_file_access/database/';",
"CREATE EXTERNAL TABLE local_file_access_external WITH (
location = 'local_file_access/table.parquet',
format = 'parquet'
);",
];
for statement in statements {
let error = try_execute_sql(&instance, statement).await.unwrap_err();
assert_eq!(error.status_code(), StatusCode::InvalidArguments);
assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
let message = error.output_msg();
assert!(
message.contains("SQL access to the local filesystem is disabled"),
"{message}"
);
assert!(
message.contains("use S3, OSS, GCS, or AzBlob instead"),
"{message}"
);
}
}
#[apply(standalone_instance_case)]
async fn test_execute_external_create(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let tmp_dir = temp_dir::create_temp_dir("test_execute_external_create");
let tmp_dir = create_local_file_test_dir("test_execute_external_create");
let location = prepare_path(tmp_dir.path().to_str().unwrap());
let output = execute_sql(
@@ -700,11 +741,11 @@ async fn test_execute_external_create(instance: Arc<dyn MockInstance>) {
assert!(matches!(output, OutputData::AffectedRows(0)));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_external_create_infer_format(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let tmp_dir = temp_dir::create_temp_dir("test_execute_external_create_infer_format");
let tmp_dir = create_local_file_test_dir("test_execute_external_create_infer_format");
let location = prepare_path(tmp_dir.path().to_str().unwrap());
let output = execute_sql(
@@ -716,11 +757,11 @@ async fn test_execute_external_create_infer_format(instance: Arc<dyn MockInstanc
assert!(matches!(output, OutputData::AffectedRows(0)));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_external_create_without_ts(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let tmp_dir = temp_dir::create_temp_dir("test_execute_external_create_without_ts");
let tmp_dir = create_local_file_test_dir("test_execute_external_create_without_ts");
let location = prepare_path(tmp_dir.path().to_str().unwrap());
let result = try_execute_sql(
@@ -741,11 +782,11 @@ async fn test_execute_external_create_without_ts(instance: Arc<dyn MockInstance>
));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_external_create_with_invalid_ts(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let tmp_dir = temp_dir::create_temp_dir("test_execute_external_create_with_invalid_ts");
let tmp_dir = create_local_file_test_dir("test_execute_external_create_with_invalid_ts");
let location = prepare_path(tmp_dir.path().to_str().unwrap());
let result = try_execute_sql(
@@ -785,7 +826,7 @@ async fn test_execute_external_create_with_invalid_ts(instance: Arc<dyn MockInst
));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_parquet(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -860,7 +901,7 @@ async fn test_execute_query_external_table_parquet(instance: Arc<dyn MockInstanc
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_orc(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -945,7 +986,7 @@ async fn test_execute_query_external_table_orc(instance: Arc<dyn MockInstance>)
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_orc_with_schema(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -1003,7 +1044,7 @@ async fn test_execute_query_external_table_orc_with_schema(instance: Arc<dyn Moc
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_csv(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -1058,7 +1099,7 @@ async fn test_execute_query_external_table_csv(instance: Arc<dyn MockInstance>)
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_copy_from_headerless_csv(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let csv_path = find_testing_resource("/tests/data/csv/headerless.csv");
@@ -1183,10 +1224,10 @@ async fn test_execute_copy_from_headerless_csv(instance: Arc<dyn MockInstance>)
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_copy_from_csv_strict_headers(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
let tmp_dir = temp_dir::create_temp_dir("test_execute_copy_from_csv_strict_headers");
let tmp_dir = create_local_file_test_dir("test_execute_copy_from_csv_strict_headers");
let matching_path = tmp_dir.path().join("matching.csv");
let unknown_path = tmp_dir.path().join("unknown.csv");
let missing_path = tmp_dir.path().join("missing.csv");
@@ -1315,7 +1356,7 @@ async fn test_execute_copy_from_csv_strict_headers(instance: Arc<dyn MockInstanc
));
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_json(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -1377,7 +1418,7 @@ async fn test_execute_query_external_table_json(instance: Arc<dyn MockInstance>)
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_json_with_schema(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -1448,7 +1489,7 @@ async fn test_execute_query_external_table_json_with_schema(instance: Arc<dyn Mo
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_json_type_cast(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -1523,7 +1564,7 @@ async fn test_execute_query_external_table_json_type_cast(instance: Arc<dyn Mock
check_output_stream(output, expect).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_query_external_table_json_default_ts_column(instance: Arc<dyn MockInstance>) {
unsafe {
std::env::set_var("TZ", "UTC");
@@ -2584,7 +2625,7 @@ async fn test_execute_copy_from_azblob(instance: Arc<dyn MockInstance>) {
}
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_copy_from_orc_with_cast(instance: Arc<dyn MockInstance>) {
common_telemetry::init_default_ut_logging();
let instance = instance.frontend();
@@ -2623,7 +2664,7 @@ async fn test_execute_copy_from_orc_with_cast(instance: Arc<dyn MockInstance>) {
check_output_stream(output, expected).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_execute_copy_from_orc(instance: Arc<dyn MockInstance>) {
common_telemetry::init_default_ut_logging();
let instance = instance.frontend();
@@ -2661,7 +2702,7 @@ async fn test_execute_copy_from_orc(instance: Arc<dyn MockInstance>) {
check_output_stream(output, expected).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_cast_type_issue_1594(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
@@ -2698,7 +2739,7 @@ async fn test_cast_type_issue_1594(instance: Arc<dyn MockInstance>) {
check_output_stream(output, expected).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_copy_from_csv_skip_bad_records(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
@@ -3116,7 +3157,7 @@ WITH(
}
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_copy_parquet_map_to_json(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
@@ -3183,7 +3224,7 @@ async fn test_copy_parquet_map_to_json(instance: Arc<dyn MockInstance>) {
check_output_stream(output, &expected).await;
}
#[apply(both_instances_cases)]
#[apply(standalone_instance_case)]
async fn test_copy_parquet_map_to_binary(instance: Arc<dyn MockInstance>) {
let instance = instance.frontend();
+10
View File
@@ -439,6 +439,16 @@ pub fn prepare_path(p: &str) -> String {
p.to_string()
}
/// Creates a temporary local-file test directory inside the standalone test sandbox.
pub fn create_local_file_test_dir(prefix: &str) -> tempfile::TempDir {
let parent = find_workspace_path("target/local-file-tests");
std::fs::create_dir_all(&parent).unwrap();
tempfile::Builder::new()
.prefix(prefix)
.tempdir_in(parent)
.unwrap()
}
/// Find the testing file resource under workspace root to be used in object store.
pub fn find_testing_resource(path: &str) -> String {
let p = find_workspace_path(path).display().to_string();
@@ -0,0 +1,49 @@
CREATE TABLE local_file_access_distributed (
ts TIMESTAMP TIME INDEX,
host STRING PRIMARY KEY,
val DOUBLE
);
Affected Rows: 0
INSERT INTO local_file_access_distributed VALUES (1, 'host-1', 1.0);
Affected Rows: 1
COPY local_file_access_distributed
TO 'local_file_access/table.parquet';
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/table.parquet'; use S3, OSS, GCS, or AzBlob instead
COPY local_file_access_distributed
FROM 'local_file_access/table.parquet';
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/table.parquet'; use S3, OSS, GCS, or AzBlob instead
COPY (SELECT * FROM local_file_access_distributed)
TO 'local_file_access/query.parquet';
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/query.parquet'; use S3, OSS, GCS, or AzBlob instead
COPY DATABASE public
TO 'local_file_access/database/';
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/database/'; use S3, OSS, GCS, or AzBlob instead
COPY DATABASE public
FROM 'local_file_access/database/';
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/database/'; use S3, OSS, GCS, or AzBlob instead
CREATE EXTERNAL TABLE local_file_access_external
WITH (
location = 'local_file_access/table.parquet',
format = 'parquet'
);
Error: 1004(InvalidArguments), SQL access to the local filesystem is disabled for 'local_file_access/table.parquet'; use S3, OSS, GCS, or AzBlob instead
DROP TABLE local_file_access_distributed;
Affected Rows: 0
@@ -0,0 +1,30 @@
CREATE TABLE local_file_access_distributed (
ts TIMESTAMP TIME INDEX,
host STRING PRIMARY KEY,
val DOUBLE
);
INSERT INTO local_file_access_distributed VALUES (1, 'host-1', 1.0);
COPY local_file_access_distributed
TO 'local_file_access/table.parquet';
COPY local_file_access_distributed
FROM 'local_file_access/table.parquet';
COPY (SELECT * FROM local_file_access_distributed)
TO 'local_file_access/query.parquet';
COPY DATABASE public
TO 'local_file_access/database/';
COPY DATABASE public
FROM 'local_file_access/database/';
CREATE EXTERNAL TABLE local_file_access_external
WITH (
location = 'local_file_access/table.parquet',
format = 'parquet'
);
DROP TABLE local_file_access_distributed;
@@ -0,0 +1,55 @@
CREATE TABLE local_file_access_source (
ts TIMESTAMP TIME INDEX,
host STRING PRIMARY KEY,
val DOUBLE
);
Affected Rows: 0
INSERT INTO local_file_access_source VALUES
(1, 'host-1', 1.0),
(2, 'host-2', 2.0);
Affected Rows: 2
COPY local_file_access_source TO 'local_file_access/table.parquet';
Affected Rows: 2
CREATE EXTERNAL TABLE local_file_access_external
WITH (
location = 'local_file_access/table.parquet',
format = 'parquet'
);
Affected Rows: 0
SELECT COUNT(*) FROM local_file_access_external;
+----------+
| count(*) |
+----------+
| 2 |
+----------+
COPY (SELECT * FROM local_file_access_source)
TO 'local_file_access/query.parquet';
Affected Rows: 2
COPY DATABASE public TO 'local_file_access/database/';
Affected Rows: 4
COPY local_file_access_source FROM '../escape.parquet';
Error: 1004(InvalidArguments), Local filesystem path '../escape.parquet' is outside the configured copy root or is unsafe: '..' path components are not allowed; use a path relative to the copy root or use S3, OSS, GCS, or AzBlob
DROP TABLE local_file_access_external;
Affected Rows: 0
DROP TABLE local_file_access_source;
Affected Rows: 0
@@ -0,0 +1,29 @@
CREATE TABLE local_file_access_source (
ts TIMESTAMP TIME INDEX,
host STRING PRIMARY KEY,
val DOUBLE
);
INSERT INTO local_file_access_source VALUES
(1, 'host-1', 1.0),
(2, 'host-2', 2.0);
COPY local_file_access_source TO 'local_file_access/table.parquet';
CREATE EXTERNAL TABLE local_file_access_external
WITH (
location = 'local_file_access/table.parquet',
format = 'parquet'
);
SELECT COUNT(*) FROM local_file_access_external;
COPY (SELECT * FROM local_file_access_source)
TO 'local_file_access/query.parquet';
COPY DATABASE public TO 'local_file_access/database/';
COPY local_file_access_source FROM '../escape.parquet';
DROP TABLE local_file_access_external;
DROP TABLE local_file_access_source;
+1
View File
@@ -24,6 +24,7 @@ broker_endpoints = {kafka_wal_broker_endpoints | unescaped}
[storage]
type = 'File'
data_home = '{data_home}'
copy_root = '{copy_root}'
[grpc]
bind_addr = '{addrs.grpc_addr}'
+4 -1
View File
@@ -328,7 +328,10 @@ impl CompatCommand {
.unwrap();
let sqlness_home = temp_dir.keep();
unsafe {
std::env::set_var("SQLNESS_HOME", sqlness_home.display().to_string());
std::env::set_var(
"SQLNESS_HOME",
sqlness_home.join("copy").display().to_string(),
);
}
// ---- 7. Build interceptor registry ----
+4 -1
View File
@@ -116,7 +116,10 @@ impl EnvController for Env {
}
unsafe {
std::env::set_var("SQLNESS_HOME", self.sqlness_home.display().to_string());
std::env::set_var(
"SQLNESS_HOME",
self.sqlness_home.join("copy").display().to_string(),
);
}
match mode {
"standalone" => self.start_standalone(id).await,
+2
View File
@@ -141,6 +141,7 @@ pub enum ServerMode {
struct ConfigContext {
wal_dir: String,
data_home: String,
copy_root: String,
procedure_dir: String,
is_raft_engine: bool,
kafka_wal_broker_endpoints: String,
@@ -345,6 +346,7 @@ impl ServerMode {
let ctx = ConfigContext {
wal_dir,
data_home: data_home.display().to_string(),
copy_root: sqlness_home.join("copy").display().to_string(),
procedure_dir,
is_raft_engine: db_ctx.is_raft_engine(),
kafka_wal_broker_endpoints: db_ctx.kafka_wal_broker_endpoints(),