Repository navigation
Feat/python sdk cipher #1453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/python sdk cipher #1453
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| //! The credentials a `Cipher` is built with, found as every SDK finds them. | ||
| //! | ||
| //! `stack_auth::AutoStrategy` reads `CS_CLIENT_ACCESS_KEY` and | ||
| //! `CS_WORKSPACE_CRN`, then the profile store (`~/.cipherstash`, or | ||
| //! `CS_CONFIG_PATH`). With a profile and no access key, it reads and parses | ||
| //! the profile's token file. It makes no request. The Python tests cover it: | ||
| //! a Rust test would set environment variables that other tests in the same | ||
| //! process read. | ||
|
|
||
| use stack_auth::{AuthError, AutoStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError}; | ||
|
|
||
| use crate::refusal::Refusal; | ||
|
|
||
| /// Check that the credentials are there. | ||
| /// | ||
| /// The strategy `AutoStrategy` finds is built over [`NoRequest`], so no HTTP | ||
| /// client, TLS configuration or root store is built, and nothing is kept. | ||
| /// stack-auth's `http` feature is off (`Cargo.toml`), so none is compiled | ||
| /// either. The ticket that makes the first request turns it on and keeps a | ||
| /// strategy with a real transport. The work here is the environment, the | ||
| /// service URL for the CRN's region, and one small file where there is a | ||
| /// profile. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// The engine's refusal, such as `stack_auth_not_authenticated` when there | ||
| /// are no credentials. The message names the variable or file to fix, for | ||
| /// a Python user. | ||
| pub fn detect() -> Result<(), Refusal> { | ||
| AutoStrategy::builder() | ||
| .transport(NoRequest) | ||
| .detect() | ||
| .map(drop) | ||
| .map_err(|error| { | ||
| let refused = Refusal::of(&error); | ||
| Refusal { | ||
| message: message(&error).unwrap_or(refused.message.clone()), | ||
| ..refused | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| /// The message for a Python user, where the engine's names a Rust API or | ||
| /// no variable at all. `None` keeps the engine's message and help. | ||
| fn message(error: &AuthError) -> Option<String> { | ||
| let text = match error { | ||
| AuthError::NotAuthenticated(_) => "no credentials were found. Set CS_CLIENT_ACCESS_KEY \ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix in a follow-up: Impact: A user who ran Evidence: Fix: Write a message that covers both cases. Or name the profile in the message, the same way the AuthError::NotAuthenticated(_) => "no usable credentials were found. Set CS_CLIENT_ACCESS_KEY \
and CS_WORKSPACE_CRN, or log in again with `stash auth login` (the profile is in \
~/.cipherstash, or in CS_CONFIG_PATH when set)."
.to_owned(),Found by 1 model: claude |
||
| and CS_WORKSPACE_CRN, or log in with `stash auth login`." | ||
| .to_owned(), | ||
| AuthError::MissingWorkspaceCrn(_) => { | ||
| "CS_CLIENT_ACCESS_KEY is set and CS_WORKSPACE_CRN is not. Set both.".to_owned() | ||
| } | ||
| AuthError::InvalidAccessKey(_) => { | ||
| format!("{error}. An access key has the form CSAK<key-id>.<secret>.") | ||
| } | ||
| AuthError::InvalidCrn(_) => format!( | ||
| "{error}. CS_WORKSPACE_CRN has the form crn:<region>:<workspace-id>, \ | ||
| such as crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY." | ||
| ), | ||
| AuthError::Region(_) => { | ||
| format!("{error}. The region is the middle part of CS_WORKSPACE_CRN.") | ||
| } | ||
| AuthError::InvalidUrl(_) => format!("{error}. CS_CTS_HOST, when set, must be a URL."), | ||
| AuthError::Store(_) => format!( | ||
| "{error}. The profile is in ~/.cipherstash, or in CS_CONFIG_PATH when set. \ | ||
| Log in again with `stash auth login`, or set CS_CLIENT_ACCESS_KEY." | ||
| ), | ||
| _ => return None, | ||
| }; | ||
| Some(text) | ||
| } | ||
|
|
||
| /// A transport that sends nothing. A strategy cannot be built without one. | ||
| struct NoRequest; | ||
|
|
||
| impl HttpTransport for NoRequest { | ||
| async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, RequestError> { | ||
| Err(RequestError(Box::new(Refused))) | ||
| } | ||
| } | ||
|
|
||
| #[derive(Debug)] | ||
| struct Refused; | ||
|
|
||
| impl std::fmt::Display for Refused { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.write_str("this strategy only checks that the credentials exist; it sends no request") | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for Refused {} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| //! An engine error, in the four fields every SDK error has. | ||
|
|
||
| use miette::Diagnostic; | ||
|
|
||
| /// An engine error as the Python side raises it. | ||
| #[derive(Clone, Debug, PartialEq, Eq)] | ||
| pub struct Refusal { | ||
| /// The engine's diagnostic code, with `::` replaced by `_`: | ||
| /// `stack_encrypt_dynamic_plan`. | ||
| pub code: String, | ||
| /// The engine's message, with its help after it where it gives one. A | ||
| /// caller whose engine help names a Rust API writes its own message. | ||
| pub message: String, | ||
| /// The plan field at fault, where the engine names one. | ||
| pub field: Option<String>, | ||
| /// The engine's reason token, such as `no_fields`, where it gives one. | ||
| pub reason: Option<String>, | ||
| } | ||
|
|
||
| impl Refusal { | ||
| /// The code and message of `error`, with no field or reason. | ||
| pub fn of(error: &dyn Diagnostic) -> Self { | ||
| let message = match error.help() { | ||
| Some(help) => format!("{error} {help}"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix in a follow-up: Impact: Most engine messages do not end with punctuation. A Python user then reads one broken sentence. This also changes the plan refusals that Evidence: Fix: Add a full stop when the message does not end with one. Also decide whether plan refusals should carry the engine help. If not, keep let text = error.to_string();
let message = match error.help() {
Some(help) if text.ends_with(['.', '!', '?']) => format!("{text} {help}"),
Some(help) => format!("{text}. {help}"),
None => text,
};Found by 1 model: claude |
||
| None => error.to_string(), | ||
| }; | ||
| Self { | ||
| code: error.code().map_or_else( | ||
| || "stack_unknown".to_owned(), | ||
| |code| python_code(&code.to_string()), | ||
| ), | ||
| message, | ||
| field: None, | ||
| reason: None, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// An engine diagnostic code as the SDK's `code`: `stack_encrypt::aead` | ||
| /// becomes `stack_encrypt_aead`. | ||
| pub fn python_code(engine_code: &str) -> String { | ||
| engine_code.replace("::", "_") | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::python_code; | ||
|
|
||
| #[test] | ||
| fn engine_codes_replace_the_path_separator() { | ||
| assert_eq!(python_code("stack_encrypt::aead"), "stack_encrypt_aead"); | ||
| assert_eq!( | ||
| python_code("stack_kms::load_keyset_forbidden"), | ||
| "stack_kms_load_keyset_forbidden" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional:
unwrap_or(refused.message.clone())clones the default message every time, also whenmessage(&error)returns a replacement.Impact: This is a small, needless allocation on a cold path. The bigger cost is that the closure is harder to read than it has to be.
Fix: Move the message out of
refusedinstead of cloning it.Found by 1 model: claude