Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ The legal and provenance records are maintained in
[`specs/0023-local-ipc-capability.md`](specs/0023-local-ipc-capability.md),
[`specs/0024-macos-executable-runtime-roots.md`](specs/0024-macos-executable-runtime-roots.md),
[`specs/0025-dynamic-permission-escalation.md`](specs/0025-dynamic-permission-escalation.md),
[`specs/0026-structured-configuration-diagnostics.md`](specs/0026-structured-configuration-diagnostics.md),
[`NOTICE`](NOTICE), [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md), and
[`UPSTREAM.md`](UPSTREAM.md).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,23 @@ class Cageforge private constructor(
context: RuntimeContext = RuntimeContext(file.toAbsolutePath().parent ?: Path.of(".")),
grant: PermissionGrant? = null,
request: PermissionRequest? = null,
): Cageforge = fromToml(Files.readString(file), profileName, context, grant, request)
): Cageforge {
try {
return fromToml(Files.readString(file), profileName, context, grant, request)
} catch (error: CageforgeConfigurationException) {
if (error.configPath != null) throw error
throw CageforgeConfigurationException(
error.message ?: "configuration error",
error.code,
file.toAbsolutePath().toString(),
error.profile,
error.platform,
error.field,
error.line,
error.column,
error.command,
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,38 @@ open class CageforgeException
class UnsupportedPlatformException(message: String) : CageforgeException(message)

/** Raised when TOML configuration cannot be parsed or resolved. */
class CageforgeConfigurationException(message: String) : CageforgeException(message)
class CageforgeConfigurationException(
message: String,
val code: String,
val configPath: String?,
val profile: String?,
val platform: String?,
val field: String?,
val line: Int?,
val column: Int?,
val command: String? = null,
) : CageforgeException(message) {
/** Creates a category-only configuration failure for host-side validation. */
constructor(message: String) : this(message, "configuration_error", null, null, null, null, null, null)
}

/** Raised when the native Cageforge runtime cannot be initialized. */
class CageforgeInitializationException(message: String) : CageforgeException(message)

/** Raised when a command cannot be launched through the native sandbox. */
class CageforgeLaunchException(message: String) : CageforgeException(message)
class CageforgeLaunchException(
message: String,
val code: String,
val configPath: String?,
val profile: String?,
val platform: String?,
val field: String?,
val line: Int?,
val column: Int?,
val command: String?,
) : CageforgeException(message) {
constructor(message: String) : this(message, "native_launch_error", null, null, null, null, null, null, null)
}

/** Raised when a trusted preflight grant is missing, invalid, or insufficient. */
open class CageforgePermissionException(message: String) : CageforgeException(message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,52 @@ class RuntimeContextTest {
assertFailsWith<IllegalArgumentException> { RuntimeContext(Path.of("relative")) }
}

@Test
fun configurationExceptionKeepsStructuredDiagnosticFields() {
val error =
CageforgeConfigurationException(
"invalid configuration",
"invalid_value",
"/tmp/tool.toml",
"broken",
"macos",
"command.program",
12,
3,
)
assertEquals("invalid_value", error.code)
assertEquals("/tmp/tool.toml", error.configPath)
assertEquals("broken", error.profile)
assertEquals("macos", error.platform)
assertEquals("command.program", error.field)
assertEquals(12, error.line)
assertEquals(3, error.column)
}

@Test
fun launchExceptionKeepsNativeSourceFields() {
val error =
CageforgeLaunchException(
"native launch failed",
"macos_program_requires_executable_root",
"/tmp/tool.toml",
"tool",
"macos",
"runtime.executable_roots",
8,
1,
"/opt/tool/bin/runner --worker one",
)
assertEquals("macos_program_requires_executable_root", error.code)
assertEquals("/tmp/tool.toml", error.configPath)
assertEquals("tool", error.profile)
assertEquals("macos", error.platform)
assertEquals("runtime.executable_roots", error.field)
assertEquals(8, error.line)
assertEquals(1, error.column)
assertEquals("/opt/tool/bin/runner --worker one", error.command)
}

@Test
fun windowsSetupIsTypedAndNonDestructiveOnNonWindows() {
if (WindowsSetup.isSupported()) return
Expand Down
182 changes: 179 additions & 3 deletions crates/bindings/cageforge-java/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0

use jni::errors::ErrorPolicy;
use jni::objects::JClass;
use jni::objects::{JClass, JObject, JThrowable, JValue};
use jni::{Env, EnvUnowned};

/// Stable exception categories exposed by the JVM binding.
Expand Down Expand Up @@ -90,17 +90,117 @@ impl BindingErrorKind {
}
}

#[derive(Debug)]
pub(crate) struct BindingDiagnostic {
pub(crate) code: String,
pub(crate) config_path: Option<String>,
pub(crate) profile: Option<String>,
pub(crate) platform: Option<String>,
pub(crate) field: Option<String>,
pub(crate) command: Option<String>,
pub(crate) line: Option<i32>,
pub(crate) column: Option<i32>,
}

impl BindingDiagnostic {
pub(crate) fn from_config(error: &cageforge::ConfigError) -> Self {
let diagnostic = error.diagnostic();
let location = diagnostic.location();
Self {
code: diagnostic.code().to_owned(),
config_path: diagnostic
.config_path()
.map(|path| path.display().to_string()),
profile: diagnostic.profile().map(str::to_owned),
platform: diagnostic
.platform()
.map(|platform| platform.as_str().to_owned()),
field: diagnostic.field().map(str::to_owned),
command: diagnostic.command().map(str::to_owned),
line: location.and_then(|value| i32::try_from(value.line).ok()),
column: location.and_then(|value| i32::try_from(value.column).ok()),
}
}

pub(crate) fn from_runtime(
source: &cageforge::ProfileSourceContext,
command: &cageforge::CommandRequest,
error: &(dyn std::error::Error + 'static),
) -> (Self, String) {
let command = display_command(command);
let diagnostic =
cageforge::config_diagnostic_for_runtime_failure(source, Some(&command), error);
let location = diagnostic.location();
(
Self {
code: diagnostic.code().to_owned(),
config_path: diagnostic
.config_path()
.map(|path| path.display().to_string()),
profile: diagnostic.profile().map(str::to_owned),
platform: diagnostic
.platform()
.map(|platform| platform.as_str().to_owned()),
field: diagnostic.field().map(str::to_owned),
command: diagnostic.command().map(str::to_owned),
line: location.and_then(|value| i32::try_from(value.line).ok()),
column: location.and_then(|value| i32::try_from(value.column).ok()),
},
diagnostic.render_human(),
)
}
}

fn display_command(command: &cageforge::CommandRequest) -> String {
std::iter::once(command.command().program())
.chain(
command
.command()
.args()
.iter()
.map(std::ffi::OsString::as_os_str),
)
.map(|part| part.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ")
}

#[derive(Debug)]
pub(crate) struct BindingError {
pub(crate) kind: BindingErrorKind,
message: String,
diagnostic: Option<Box<BindingDiagnostic>>,
}

impl BindingError {
pub(crate) fn new(kind: BindingErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
diagnostic: None,
}
}

pub(crate) fn configuration(error: cageforge::ConfigError) -> Self {
let diagnostic = error.diagnostic();
Self {
kind: BindingErrorKind::Configuration,
message: diagnostic.render_human(),
diagnostic: Some(Box::new(BindingDiagnostic::from_config(&error))),
}
}

pub(crate) fn runtime(
kind: BindingErrorKind,
source: &cageforge::ProfileSourceContext,
command: &cageforge::CommandRequest,
error: &(dyn std::error::Error + 'static),
) -> Self {
let (diagnostic, rendered) = BindingDiagnostic::from_runtime(source, command, error);
Self {
message: rendered,
kind,
diagnostic: Some(Box::new(diagnostic)),
}
}

Expand All @@ -125,6 +225,7 @@ impl From<String> for BindingError {
Self {
kind: BindingErrorKind::Internal,
message,
diagnostic: None,
}
}
}
Expand All @@ -141,6 +242,74 @@ impl From<jni::errors::Error> for BindingError {
}
}

fn throw_diagnostic_exception(
env: &mut Env<'_>,
class: &JClass<'_>,
message: &str,
diagnostic: &BindingDiagnostic,
) -> jni::errors::Result<()> {
let message = env.new_string(message)?;
let code = env.new_string(&diagnostic.code)?;
let config_path = diagnostic
.config_path
.as_deref()
.map(|value| env.new_string(value))
.transpose()?;
let profile = diagnostic
.profile
.as_deref()
.map(|value| env.new_string(value))
.transpose()?;
let platform = diagnostic
.platform
.as_deref()
.map(|value| env.new_string(value))
.transpose()?;
let field = diagnostic
.field
.as_deref()
.map(|value| env.new_string(value))
.transpose()?;
let command = diagnostic
.command
.as_deref()
.map(|value| env.new_string(value))
.transpose()?;
let line = match diagnostic.line {
Some(value) => env.new_object(
jni::jni_str!("java/lang/Integer"),
jni::jni_sig!("(I)V"),
&[JValue::Int(value)],
)?,
None => JObject::null(),
};
let column = match diagnostic.column {
Some(value) => env.new_object(
jni::jni_str!("java/lang/Integer"),
jni::jni_sig!("(I)V"),
&[JValue::Int(value)],
)?,
None => JObject::null(),
};
let object = env.new_object(
class,
jni::jni_sig!("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)V"),
&[
JValue::Object(message.as_ref()),
JValue::Object(code.as_ref()),
JValue::Object(config_path.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())),
JValue::Object(profile.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())),
JValue::Object(platform.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())),
JValue::Object(field.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())),
JValue::Object(&line),
JValue::Object(&column),
JValue::Object(command.as_ref().map_or(JObject::null().as_ref(), |value| value.as_ref())),
],
)?;
let throwable = unsafe { JThrowable::from_raw(env, object.into_raw()) };
env.throw(throwable)
}

pub(crate) struct ThrowCageforgeException;

impl<T: Default> ErrorPolicy<T, BindingError> for ThrowCageforgeException {
Expand All @@ -153,8 +322,15 @@ impl<T: Default> ErrorPolicy<T, BindingError> for ThrowCageforgeException {
) -> jni::errors::Result<T> {
if !env.exception_check() {
let class = error.kind.class(env)?;
let message = jni::strings::JNIString::new(error.to_string());
let _ = env.throw_new(class, message);
if let Some(diagnostic) = error.diagnostic.as_ref() {
if throw_diagnostic_exception(env, &class, &error.message, diagnostic).is_err() {
let message = jni::strings::JNIString::new(error.to_string());
let _ = env.throw_new(class, message);
}
} else {
let message = jni::strings::JNIString::new(error.to_string());
let _ = env.throw_new(class, message);
}
}
Ok(T::default())
}
Expand Down
Loading
Loading