Skip to content

Commit 2fc8bd6

Browse files
claudecoderdan
authored andcommitted
feat(golang): both guests record the full error behind every failing export, with a leak test
The crypto and credential guests now run every export through `stack_guest_abi::abi::export`, so each exports `se_last_error` and every failing export leaves an error for it. Every place that maps an error to a status also records it: `status::fail_error`, `fail_dynamic`, `fail_auth` and `fail_profile` record the stack-encrypt, stack-kms, stack-auth or stack-profile error with its fields and return the status the old mapping gave. Where a guest refuses input before any library sees it (bytes that are not the codec, a malformed options object or config, a call out of order) it records a `GuestError` naming what it refused, and the config error names the key, never its value. The status numbers are unchanged and so are their tests. The credential guest's `status_for_auth` matches stack-auth's variants instead of hand-typed `error_code()` strings, so a renamed code can no longer fall through to "other auth failure"; a test pins that every variant gets the status the string table gave it. The crypto guest no longer copies eql-bindings' JSON parser message into a stored-value refusal: serde_json quotes the input it refused, and the input is stored ciphertext. The leak tests (`tests/leak.rs` in each guest) drive every error path a native test can reach with marker values where a caller's data would be — plaintext, contexts, ciphertext, a client key, an access token or access key, a ZeroKMS response body — and assert no marker appears in any encoded error, at any depth, keys included. Each pins the codes it reached, so a path that stops being driven fails there. Reverting either the context-descriptor rule or the JSON-message rule makes them fail. The credential guest's lockfile moves `vitaminc-aead-value` from 0.5.0 to 0.5.1, the version stack-guest-abi builds against and the crypto guest already uses. Refs #1100 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf
1 parent f7b574f commit 2fc8bd6

14 files changed

Lines changed: 1441 additions & 170 deletions

File tree

‎languages/golang/auth/guest/Cargo.lock‎

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎languages/golang/auth/guest/src/abi.rs‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@
2525
2626
use std::panic::{catch_unwind, AssertUnwindSafe};
2727

28-
use stack_guest_abi::abi::{err_status, input, ok_buffer};
29-
use stack_guest_abi::buffers;
30-
use stack_guest_abi::status::STATUS_INTERNAL;
28+
use stack_guest_abi::abi::{export, input};
29+
use stack_guest_abi::{buffers, last_error};
3130

3231
use crate::{auth, ops};
3332

@@ -42,27 +41,25 @@ type Op1 = fn(&[u8]) -> Result<Vec<u8>, u32>;
4241
type Op2 = fn(&[u8], &[u8]) -> Result<Vec<u8>, u32>;
4342

4443
/// Run a two-input operation as an export: validate both pairs, run,
45-
/// pack. A panic is `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the
46-
/// catch is belt-and-braces for an unwinding build).
44+
/// pack, through the shared [`export`] wrapper — which clears the last
45+
/// error first, records one for any failure, and makes a panic
46+
/// `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the catch is
47+
/// belt-and-braces for an unwinding build).
4748
fn export2(a_ptr: *const u8, a_len: u32, b_ptr: *const u8, b_len: u32, op: Op2) -> u64 {
48-
catch_unwind(AssertUnwindSafe(|| {
49+
export(|| {
4950
// SAFETY: host-owned ranges the export was handed; the borrows end
5051
// when `op` returns, inside the call, and nothing here writes to
5152
// linear memory while they are live.
5253
let a = unsafe { input(a_ptr, a_len)? };
5354
let b = unsafe { input(b_ptr, b_len)? };
5455
op(a, b)
55-
}))
56-
.unwrap_or(Err(STATUS_INTERNAL))
57-
.map_or_else(err_status, ok_buffer)
56+
})
5857
}
5958

6059
/// [`export2`] for a one-input operation.
6160
fn export1(a_ptr: *const u8, a_len: u32, op: Op1) -> u64 {
6261
// SAFETY: as in `export2`.
63-
catch_unwind(AssertUnwindSafe(|| op(unsafe { input(a_ptr, a_len)? })))
64-
.unwrap_or(Err(STATUS_INTERNAL))
65-
.map_or_else(err_status, ok_buffer)
62+
export(|| op(unsafe { input(a_ptr, a_len)? }))
6663
}
6764

6865
/// The current workspace id of the store at `dir`. See
@@ -241,5 +238,6 @@ pub unsafe extern "C" fn sa_auth_free(ptr: *const u8, len: u32) -> u64 {
241238
#[no_mangle]
242239
pub extern "C" fn sa_shutdown() {
243240
let _ = catch_unwind(AssertUnwindSafe(auth::clear));
241+
let _ = catch_unwind(AssertUnwindSafe(last_error::clear));
244242
let _ = catch_unwind(AssertUnwindSafe(buffers::wipe_all));
245243
}

‎languages/golang/auth/guest/src/auth.rs‎

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ use stack_profile::ProfileStore;
1616
use zeroize::Zeroizing;
1717

1818
use crate::host::{HostOidcProvider, WasiAuthTransport};
19-
use crate::status::{
20-
status_for_auth, status_for_profile, STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED,
21-
STATUS_ENCODING, STATUS_STATE,
22-
};
19+
use stack_auth::AuthError;
20+
use stack_guest_abi::last_error::{malformed, out_of_order};
21+
22+
use crate::status::{fail_auth, fail_profile, STATUS_AUTH_REFRESH_REQUIRED};
2323

2424
#[derive(Deserialize)]
2525
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
@@ -62,13 +62,16 @@ thread_local! {
6262

6363
fn parse_base_url(value: Option<String>) -> Result<Option<url::Url>, u32> {
6464
value
65-
.map(|v| v.parse::<url::Url>().map_err(|_| STATUS_ENCODING))
65+
.map(|v| {
66+
v.parse::<url::Url>()
67+
.map_err(|_| malformed("the strategy config's base_url is not a URL"))
68+
})
6669
.transpose()
6770
}
6871

6972
pub fn validate_crn(bytes: &[u8]) -> Result<Vec<u8>, u32> {
70-
let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?;
71-
let _: Crn = text.parse().map_err(|_| STATUS_AUTH_CONFIG)?;
73+
let text = std::str::from_utf8(bytes).map_err(|_| malformed("the CRN is not UTF-8"))?;
74+
let _: Crn = text.parse().map_err(|e| fail_auth(&AuthError::from(e)))?;
7275
Ok(Vec::new())
7376
}
7477

@@ -78,29 +81,34 @@ pub fn validate_crn(bytes: &[u8]) -> Result<Vec<u8>, u32> {
7881
// JSON envelope itself.
7982

8083
pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> {
81-
let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?;
84+
// Never serde_json's message: it can quote the config, which carries
85+
// the access key.
86+
let config: Config = serde_json::from_slice(config)
87+
.map_err(|_| malformed("the strategy config is not a JSON object of the expected shape"))?;
8288
let strategy = match config {
8389
Config::AccessKey {
8490
crn,
8591
access_key,
8692
base_url,
8793
} => {
8894
let access_key = Zeroizing::new(access_key);
89-
let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?;
90-
let key: AccessKey = access_key.parse().map_err(|_| STATUS_AUTH_CONFIG)?;
95+
let crn: Crn = crn.parse().map_err(|e| fail_auth(&AuthError::from(e)))?;
96+
let key: AccessKey = access_key
97+
.parse()
98+
.map_err(|e| fail_auth(&AuthError::from(e)))?;
9199
let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport);
92100
if let Some(url) = parse_base_url(base_url)? {
93101
builder = builder.base_url(url);
94102
}
95-
Strategy::AccessKey(builder.build().map_err(|e| status_for_auth(&e))?)
103+
Strategy::AccessKey(builder.build().map_err(|e| fail_auth(&e))?)
96104
}
97105
Config::Oidc {
98106
crn,
99107
provider,
100108
base_url,
101109
cache_capacity,
102110
} => {
103-
let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?;
111+
let crn: Crn = crn.parse().map_err(|e| fail_auth(&AuthError::from(e)))?;
104112
let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider))
105113
.transport(WasiAuthTransport);
106114
if let Some(url) = parse_base_url(base_url)? {
@@ -109,14 +117,14 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> {
109117
if let Some(capacity) = cache_capacity {
110118
builder = builder.cache_capacity(capacity);
111119
}
112-
Strategy::Oidc(builder.build().map_err(|e| status_for_auth(&e))?)
120+
Strategy::Oidc(builder.build().map_err(|e| fail_auth(&e))?)
113121
}
114122
Config::DeviceSession {
115123
workspace_dir,
116124
base_url,
117125
} => {
118126
if workspace_dir.is_empty() {
119-
return Err(STATUS_ENCODING);
127+
return Err(malformed("the strategy config's workspace_dir is empty"));
120128
}
121129
Strategy::DeviceSession {
122130
workspace_dir,
@@ -136,22 +144,23 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> {
136144
}
137145

138146
fn parse_handle(bytes: &[u8]) -> Result<u32, u32> {
139-
let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?;
140-
text.parse::<u32>().map_err(|_| STATUS_ENCODING)
147+
let refuse = || malformed("a strategy handle is not a decimal number");
148+
let text = std::str::from_utf8(bytes).map_err(|_| refuse())?;
149+
text.parse::<u32>().map_err(|_| refuse())
141150
}
142151

143152
pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> {
144153
let id = parse_handle(handle)?;
145154
STRATEGIES.with(|items| {
146155
let items = items.borrow();
147-
let strategy = items.get(&id).ok_or(STATUS_STATE)?;
156+
let strategy = items.get(&id).ok_or_else(no_strategy)?;
148157
match strategy {
149158
Strategy::AccessKey(strategy) => block_on(strategy.get_token())
150159
.map(|token| token.as_str().as_bytes().to_vec())
151-
.map_err(|e| status_for_auth(&e)),
160+
.map_err(|e| fail_auth(&e)),
152161
Strategy::Oidc(strategy) => block_on(strategy.get_token())
153162
.map(|token| token.as_str().as_bytes().to_vec())
154-
.map_err(|e| status_for_auth(&e)),
163+
.map_err(|e| fail_auth(&e)),
155164
Strategy::DeviceSession {
156165
workspace_dir,
157166
base_url,
@@ -165,12 +174,12 @@ pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> {
165174
fn cached_device_token(workspace_dir: &str, base_url: &Option<url::Url>) -> Result<Vec<u8>, u32> {
166175
let token: Token = ProfileStore::new(workspace_dir)
167176
.load_profile()
168-
.map_err(|e| status_for_profile(&e))?;
177+
.map_err(|e| fail_profile(&e))?;
169178
if token.region().is_none() || token.client_id().is_none() {
170-
return Err(stack_guest_abi::status::STATUS_AUTH_NOT_AUTHENTICATED);
179+
return Err(fail_auth(&stack_auth::NotAuthenticated.into()));
171180
}
172181
if base_url.is_none() {
173-
let _ = token.issuer().map_err(|e| status_for_auth(&e))?;
182+
let _ = token.issuer().map_err(|e| fail_auth(&e))?;
174183
}
175184
if token.is_expired() {
176185
return Err(STATUS_AUTH_REFRESH_REQUIRED);
@@ -187,30 +196,35 @@ pub fn refresh(handle: &[u8]) -> Result<Vec<u8>, u32> {
187196
let Strategy::DeviceSession {
188197
workspace_dir,
189198
base_url,
190-
} = items.get(&id).ok_or(STATUS_STATE)?
199+
} = items.get(&id).ok_or_else(no_strategy)?
191200
else {
192-
return Err(STATUS_STATE);
201+
return Err(out_of_order("only a device session strategy refreshes"));
193202
};
194203
let store = ProfileStore::new(workspace_dir);
195204
let mut builder =
196205
DeviceSessionStrategy::with_workspace_store(store).transport(WasiAuthTransport);
197206
if let Some(url) = base_url {
198207
builder = builder.base_url(url.clone());
199208
}
200-
let strategy = builder.build().map_err(|e| status_for_auth(&e))?;
201-
let token = block_on(strategy.get_token()).map_err(|e| status_for_auth(&e))?;
209+
let strategy = builder.build().map_err(|e| fail_auth(&e))?;
210+
let token = block_on(strategy.get_token()).map_err(|e| fail_auth(&e))?;
202211
Ok(token.as_str().as_bytes().to_vec())
203212
})
204213
}
205214

206215
pub fn free(handle: &[u8]) -> Result<Vec<u8>, u32> {
207216
let id = parse_handle(handle)?;
208217
STRATEGIES.with(|items| {
209-
let _removed = items.borrow_mut().remove(&id).ok_or(STATUS_STATE)?;
218+
let _removed = items.borrow_mut().remove(&id).ok_or_else(no_strategy)?;
210219
Ok(Vec::new())
211220
})
212221
}
213222

223+
/// A handle that names no live strategy: never created, or already freed.
224+
fn no_strategy() -> u32 {
225+
out_of_order("no strategy has this handle: it was never created, or was freed")
226+
}
227+
214228
pub fn clear() {
215229
STRATEGIES.with(|items| items.borrow_mut().clear());
216230
}

‎languages/golang/auth/guest/src/ops.rs‎

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@
1212
//!
1313
//! # Errors
1414
//!
15-
//! Every function reports a [`crate::status`] code, never a message. A
16-
//! directory, id or filename that is not UTF-8, or an empty directory, is
17-
//! [`STATUS_ENCODING`]; everything else is `stack-profile`'s verdict
18-
//! ([`status_for_profile`]).
15+
//! Every function reports a [`crate::status`] code, and records the error
16+
//! behind it for `se_last_error`. A directory, id or filename that is not
17+
//! UTF-8, or an empty directory, is
18+
//! [`STATUS_ENCODING`](crate::status::STATUS_ENCODING); everything else is
19+
//! `stack-profile`'s verdict ([`fail_profile`]).
1920
//!
2021
//! # Copies
2122
//!
@@ -33,7 +34,9 @@ use stack_profile::{DeviceIdentity, ProfileStore};
3334
use vitaminc_aead_value::{transport as codec, FfiValue};
3435
use zeroize::{Zeroize, ZeroizeOnDrop};
3536

36-
use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL};
37+
use stack_guest_abi::last_error::{internal, malformed};
38+
39+
use crate::status::fail_profile;
3740

3841
/// The file `secretkey.json`, as `stack-auth`'s device client writes it
3942
/// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the
@@ -64,13 +67,13 @@ const AUTH_FILENAME: &str = "auth.json";
6467
pub fn store(dir: &[u8]) -> Result<ProfileStore, u32> {
6568
let dir = text(dir)?;
6669
if dir.is_empty() {
67-
return Err(STATUS_ENCODING);
70+
return Err(malformed("the store directory is empty"));
6871
}
6972
Ok(ProfileStore::new(dir))
7073
}
7174

7275
fn text(bytes: &[u8]) -> Result<&str, u32> {
73-
std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)
76+
std::str::from_utf8(bytes).map_err(|_| malformed("an input is not UTF-8"))
7477
}
7578

7679
fn string(value: impl Into<String>) -> FfiValue {
@@ -83,7 +86,7 @@ fn optional(value: Option<&str>) -> FfiValue {
8386

8487
fn encode(value: FfiValue) -> Result<Vec<u8>, u32> {
8588
let mut out = Vec::new();
86-
codec::encode_value(value, &mut out).map_err(|_| STATUS_INTERNAL)?;
89+
codec::encode_value(value, &mut out).map_err(|_| internal("an output did not encode"))?;
8790
Ok(out)
8891
}
8992

@@ -92,7 +95,7 @@ pub fn current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> {
9295
store(dir)?
9396
.current_workspace()
9497
.map(String::into_bytes)
95-
.map_err(|e| status_for_profile(&e))
98+
.map_err(|e| fail_profile(&e))
9699
}
97100

98101
/// Set the current workspace. The workspace must already have a directory;
@@ -101,7 +104,7 @@ pub fn set_current_workspace(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> {
101104
store(dir)?
102105
.set_current_workspace(text(id)?)
103106
.map(|()| Vec::new())
104-
.map_err(|e| status_for_profile(&e))
107+
.map_err(|e| fail_profile(&e))
105108
}
106109

107110
/// Remove the current workspace selection. Empty output; nothing to remove
@@ -110,15 +113,15 @@ pub fn clear_current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> {
110113
store(dir)?
111114
.clear_current_workspace()
112115
.map(|()| Vec::new())
113-
.map_err(|e| status_for_profile(&e))
116+
.map_err(|e| fail_profile(&e))
114117
}
115118

116119
/// The workspace ids with profile data on disk, sorted, as a codec array
117120
/// of strings.
118121
pub fn list_workspaces(dir: &[u8]) -> Result<Vec<u8>, u32> {
119122
let ids = store(dir)?
120123
.list_workspaces()
121-
.map_err(|e| status_for_profile(&e))?;
124+
.map_err(|e| fail_profile(&e))?;
122125
encode(FfiValue::Array(ids.into_iter().map(string).collect()))
123126
}
124127

@@ -129,7 +132,7 @@ pub fn list_workspaces(dir: &[u8]) -> Result<Vec<u8>, u32> {
129132
pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> {
130133
let scoped = store(dir)?
131134
.workspace_store(text(id)?)
132-
.map_err(|e| status_for_profile(&e))?;
135+
.map_err(|e| fail_profile(&e))?;
133136
path_bytes(scoped.dir())
134137
}
135138

@@ -139,7 +142,7 @@ pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> {
139142
pub fn lock_path(dir: &[u8], filename: &[u8]) -> Result<Vec<u8>, u32> {
140143
let path = store(dir)?
141144
.lock_path(text(filename)?)
142-
.map_err(|e| status_for_profile(&e))?;
145+
.map_err(|e| fail_profile(&e))?;
143146
path_bytes(&path)
144147
}
145148

@@ -148,7 +151,7 @@ fn path_bytes(path: &std::path::Path) -> Result<Vec<u8>, u32> {
148151
// this module's bug, not the caller's.
149152
path.to_str()
150153
.map(|s| s.as_bytes().to_vec())
151-
.ok_or(STATUS_INTERNAL)
154+
.ok_or_else(|| internal("a store path is not UTF-8"))
152155
}
153156

154157
/// `secretkey.json` in this store, as a codec object `{client_id,
@@ -157,7 +160,7 @@ fn path_bytes(path: &std::path::Path) -> Result<Vec<u8>, u32> {
157160
pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> {
158161
let mut file: SecretKeyFile = store(dir)?
159162
.load(SECRET_KEY_FILENAME)
160-
.map_err(|e| status_for_profile(&e))?;
163+
.map_err(|e| fail_profile(&e))?;
161164
// Moved out rather than copied: `SecretKeyFile` wipes on drop, so its
162165
// fields cannot be moved out of it directly.
163166
let client_id = std::mem::take(&mut file.client_id);
@@ -180,7 +183,7 @@ pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> {
180183
pub fn token(dir: &[u8]) -> Result<Vec<u8>, u32> {
181184
let token: Token = store(dir)?
182185
.load(AUTH_FILENAME)
183-
.map_err(|e| status_for_profile(&e))?;
186+
.map_err(|e| fail_profile(&e))?;
184187
encode(FfiValue::Object(vec![
185188
(
186189
"access_token".to_string(),
@@ -210,7 +213,7 @@ pub fn has_token(dir: &[u8]) -> Result<Vec<u8>, u32> {
210213
/// `device.json` in this store, read-only, as a codec object
211214
/// `{device_instance_id, device_name}`. Creating one is the CLI's.
212215
pub fn device_identity(dir: &[u8]) -> Result<Vec<u8>, u32> {
213-
let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| status_for_profile(&e))?;
216+
let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| fail_profile(&e))?;
214217
encode(FfiValue::Object(vec![
215218
(
216219
"device_instance_id".to_string(),

0 commit comments

Comments
 (0)