Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 141 additions & 16 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1358,18 +1358,21 @@ pub enum ConfigFileError {

const CONFIG_MIGRATION_URL: &str = "https://docs.nvidia.com/openshell/latest/how-it-works/gateways/configuration#migrate-to-schema-version-2";

/// Stable package-preflight failure with no configuration contents attached.
/// Stable package-preflight failure that never echoes configuration values.
#[derive(Debug, thiserror::Error)]
#[error(
"gateway config preflight failed: path='{}' category={} detected_version={}; the file was preserved unchanged; migrate it before restarting the gateway: {CONFIG_MIGRATION_URL}",
"gateway config preflight failed: path='{}' category={} detected_version={}{}; the file was preserved unchanged; migrate it before restarting the gateway: {CONFIG_MIGRATION_URL}",
path.display(),
category,
detected_version
detected_version,
unknown_field
)]
pub struct ConfigPreflightError {
path: PathBuf,
category: &'static str,
detected_version: String,
/// Location of an unknown key, never its value.
unknown_field: String,
}

impl ConfigPreflightError {
Expand All @@ -1378,6 +1381,7 @@ impl ConfigPreflightError {
path: path.to_path_buf(),
category: "malformed",
detected_version: SCHEMA_VERSION.to_string(),
unknown_field: String::new(),
}
}

Expand Down Expand Up @@ -1487,15 +1491,7 @@ fn without_quoted_strings(message: &str) -> String {
result
}

fn parse_and_validate(path: &Path, contents: &str) -> Result<ConfigFile, ConfigFileError> {
if contents.trim().is_empty() {
return Err(ConfigFileError::MissingVersion);
}
let file: ConfigFile = toml::from_str(contents).map_err(|error| ConfigFileError::Parse {
path: path.to_path_buf(),
detail: parse_error_detail(contents, &error),
})?;

fn validate_config(file: ConfigFile) -> Result<ConfigFile, ConfigFileError> {
match file.openshell.version {
Some(SCHEMA_VERSION) => {}
Some(version) => return Err(ConfigFileError::UnsupportedVersion { version }),
Expand Down Expand Up @@ -1688,6 +1684,56 @@ fn preflight_error(
path: path.to_path_buf(),
category,
detected_version,
unknown_field: String::new(),
}
}

/// Names an unknown key and its line. Quoted keys can hold arbitrary text, so
/// only a bare key is echoed.
fn unknown_field(contents: &str, error: &toml::de::Error) -> String {
let Some(span) = error.span() else {
return String::new();
};
let Some(before) = contents.get(..span.start) else {
return String::new();
};
if !error.message().starts_with("unknown field `") {
return String::new();
}
let Some(key) = contents.get(span) else {
return String::new();
};
let bare = !key.is_empty()
&& key
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
// Tagged enum errors can span an enclosing table instead of its unknown key.
let quoted_key = if bare {
None
} else {
if !(key.starts_with('\'') || key.starts_with('"'))
|| key.starts_with("'''")
|| key.starts_with("\"\"\"")
{
return String::new();
}
let Ok(name) = String::deserialize(toml::de::ValueDeserializer::new(key)) else {
return String::new();
};
Some(name)
};
let name = quoted_key.as_deref().unwrap_or(key);
if !error
.message()
.starts_with(&format!("unknown field `{name}`"))
{
return String::new();
}
let line = before.matches('\n').count() + 1;
if bare {
format!(" unknown_field={key} line={line}")
} else {
format!(" unknown_field line={line}")
}
}

Expand All @@ -1697,7 +1743,7 @@ pub fn preflight(path: &Path) -> Result<ConfigFile, ConfigPreflightError> {
let contents = read_config_contents(path)
.map_err(|source| preflight_error(path, "unavailable".to_string(), source))?;
let version = detected_version(&contents);
if version == "missing" {
if contents.trim().is_empty() || version == "missing" {
return Err(preflight_error(
path,
version,
Expand All @@ -1715,7 +1761,13 @@ pub fn preflight(path: &Path) -> Result<ConfigFile, ConfigPreflightError> {
},
));
}
parse_and_validate(path, &contents).map_err(|source| preflight_error(path, version, source))
let file = toml::from_str(&contents).map_err(|error| ConfigPreflightError {
path: path.to_path_buf(),
category: "malformed",
detected_version: version.clone(),
unknown_field: unknown_field(&contents, &error),
})?;
validate_config(file).map_err(|source| preflight_error(path, version, source))
}

/// Load and validate a TOML config file.
Expand All @@ -1725,7 +1777,14 @@ pub fn preflight(path: &Path) -> Result<ConfigFile, ConfigPreflightError> {
#[cfg_attr(target_os = "windows", allow(clippy::result_large_err))]
pub fn load(path: &Path) -> Result<ConfigFile, ConfigFileError> {
let contents = read_config_contents(path)?;
parse_and_validate(path, &contents)
if contents.trim().is_empty() {
return Err(ConfigFileError::MissingVersion);
}
let file = toml::from_str(&contents).map_err(|error| ConfigFileError::Parse {
path: path.to_path_buf(),
detail: parse_error_detail(&contents, &error),
})?;
validate_config(file)
}

/// Return a driver's table without gateway-level inheritance.
Expand Down Expand Up @@ -2582,14 +2641,18 @@ service_name = "openshell-gateway-dev"
"[openshell.gateway.otlp]\nendpoint = 'http://shared:4317'\nheaders = {headers}\n"
));
let error = load(tmp.path()).expect_err("an invalid headers value");
let ConfigFileError::Parse { path, detail } = &error else {
panic!("invalid headers must produce a parse error");
};
assert_eq!(path, tmp.path());
let mut report = error.to_string();
let mut source = std::error::Error::source(&error);
while let Some(error) = source {
report = format!("{report}\n{error}");
source = error.source();
}
for expected in expected {
assert!(report.contains(expected), "{headers}: {report}");
assert!(detail.contains(expected), "{headers}: {report}");
}
assert!(!report.contains("SECRETVAL"), "{headers}: {report}");
}
Expand Down Expand Up @@ -3530,6 +3593,68 @@ ssh_gateway_port = 8080
}
}

#[test]
fn preflight_names_unknown_bare_key_without_disclosing_values() {
for contents in [
"[openshell]\nversion = 2\n\n[openshell.gateway]\nguest_tls_cert = 'secret-value'\n",
"[openshell]\r\nversion = 2\r\n# π\r\n[openshell.gateway]\r\nguest_tls_cert = 'secret-value'\r\n",
] {
let tmp = write_raw_tmp(contents);
let error = preflight(tmp.path()).expect_err("removed key must fail preflight");
let diagnostic = error.to_string();
assert!(diagnostic.contains("category=malformed"), "{diagnostic}");
assert!(
diagnostic.contains("unknown_field=guest_tls_cert line=5"),
"{diagnostic}"
);
assert!(!diagnostic.contains("secret-value"), "{diagnostic}");
assert!(!format!("{error:?}").contains("secret-value"));
assert!(std::error::Error::source(&error).is_none());
assert_eq!(std::fs::read_to_string(tmp.path()).unwrap(), contents);
}

// A quoted key can hold arbitrary text, so only its line is reported.
for key in [
"'secret'",
r#""secret""#,
"'secret key'",
r#""secret\nkey""#,
r#""secret\u001bkey""#,
r#""secret\"key""#,
r#""secret`key""#,
] {
let contents =
format!("[openshell]\nversion = 2\n[openshell.gateway]\n{key} = 'secret-value'\n");
let tmp = write_raw_tmp(&contents);
let error = preflight(tmp.path()).expect_err("unknown quoted key must fail preflight");
let diagnostic = error.to_string();
assert!(diagnostic.contains("unknown_field line=4"), "{diagnostic}");
assert!(!diagnostic.contains("secret"), "{diagnostic}");
assert!(!diagnostic.contains('\u{1b}'), "{diagnostic}");
assert!(!format!("{error:?}").contains("secret"));
assert!(std::error::Error::source(&error).is_none());
assert_eq!(std::fs::read_to_string(tmp.path()).unwrap(), contents);
}

// Enum deserialization can only locate the enclosing table, not its key.
for contents in [
"[openshell]\nversion = 2\n\n[[openshell.gateway.provider_profile_sources]]\ntype = 'interceptor'\nname = 'x'\n\ntypo = 'secret-value'\n",
"[openshell]\nversion = 2\n\n[[openshell.supervisor.isolation_backends]]\nname = 'test'\n[openshell.supervisor.isolation_backends.endpoint]\nkind = 'unix'\nsocket_path = '/tmp/x'\n\ntypo = 'secret-value'\n",
] {
let tmp = write_raw_tmp(contents);
let runtime = load(tmp.path()).expect_err("unknown enum field must fail");
assert!(runtime.to_string().contains("unknown field `typo`"));
let error = preflight(tmp.path()).expect_err("unknown enum field must fail preflight");
let diagnostic = error.to_string();
assert!(diagnostic.contains("category=malformed"), "{diagnostic}");
assert!(!diagnostic.contains("unknown_field"), "{diagnostic}");
assert!(!diagnostic.contains("secret-value"), "{diagnostic}");
assert!(!format!("{error:?}").contains("secret-value"));
assert!(std::error::Error::source(&error).is_none());
assert_eq!(std::fs::read_to_string(tmp.path()).unwrap(), contents);
}
}

#[test]
fn preflight_classifies_missing_path() {
let path = Path::new("/nonexistent/openshell-preflight.toml");
Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works/gateways/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1831,7 +1831,7 @@ changing it:
openshell-gateway config preflight --path ~/.config/openshell/gateway.toml
```

Without `--path`, the command validates a nonempty `OPENSHELL_GATEWAY_CONFIG`. Otherwise, it validates an existing XDG gateway config when one is discovered. When neither source selects a config, preflight validates the effective daemon arguments. An explicit missing path, a legacy schema-v1 file, invalid TOML, a symlink, or any nonregular file fails. Preflight merges the selected file with the current `OPENSHELL_*` environment and applies the daemon's read-only startup checks. These checks include selector and socket normalization, registered-driver selection and configuration, rate-limit pairs, TLS and mTLS relationships, interceptor registrations, and supervisor middleware registrations. When a selected file omits `compute_driver`, preflight validates each configured table for an auto-detectable driver without running the runtime detection probes, which can connect local sockets or launch discovery commands. It validates configured driver TLS requirements, including the gateway CA, without requiring package-generated certificate files to exist before certificate generation. It does not construct a compute driver or connect to a transport. A failed preflight always preserves the file; it never migrates, replaces, or rewrites configuration.
Without `--path`, the command validates a nonempty `OPENSHELL_GATEWAY_CONFIG`. Otherwise, it validates an existing XDG gateway config when one is discovered. When neither source selects a config, preflight validates the effective daemon arguments. An explicit missing path, a legacy schema-v1 file, invalid TOML, a symlink, or any nonregular file fails. Preflight merges the selected file with the current `OPENSHELL_*` environment and applies the daemon's read-only startup checks. These checks include selector and socket normalization, registered-driver selection and configuration, rate-limit pairs, TLS and mTLS relationships, interceptor registrations, and supervisor middleware registrations. When a selected file omits `compute_driver`, preflight validates each configured table for an auto-detectable driver without running the runtime detection probes, which can connect local sockets or launch discovery commands. It validates configured driver TLS requirements, including the gateway CA, without requiring package-generated certificate files to exist before certificate generation. It does not construct a compute driver or connect to a transport. A failed preflight always preserves the file; it never migrates, replaces, or rewrites configuration. The failure message omits configuration values. When the parser identifies an unrecognized key token, it reports a bare key's name and line, for example `unknown_field=guest_tls_cert line=5` for the removed `guest_tls_cert`. Quoted key names are omitted; only their line is reported. Errors spanning an enclosing table keep the generic diagnostic.

Config preflight validates configuration without loading launch-signing keys, so a successful result does not confirm that the key files exist or form a usable pair. Gateway startup validates configured or discovered signing bundles. Sandbox creation checks the selected driver's launch-signing requirement before image preparation.

Expand Down
Loading