///|
/// The fixed marker written in place of a value that must not enter a cassette.
pub const REDACTION_MARKER : String = "[REDACTED]"
///|
/// Additional redaction rules supplied by the caller.
///
/// Header and query names are compared case-insensitively. json_paths accepts
/// JSON Pointer paths such as /credentials/token and a dot shorthand such as
/// credentials.token. Array indexes are supported by JSON Pointer segments.
pub(all) struct RedactionConfig {
headers : Array[String]
query_parameters : Array[String]
json_paths : Array[String]
optional_json_paths : Array[String]
} derive(Eq, @debug.Debug)
///|
/// Independent request and response rules for a recorded interaction.
///
/// Keeping the two sides separate avoids requiring a request-only path in a
/// response body (or vice versa). `RedactionConfig` remains available for the
/// symmetric compatibility API.
pub(all) struct RedactionPolicy {
request : RedactionConfig
response : RedactionConfig
} derive(Eq, @debug.Debug)
///|
/// Conservative defaults for credentials commonly sent by HTTP clients.
pub fn RedactionConfig::default() -> RedactionConfig {
{
headers: [
"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key",
"x-auth-token", "x-access-token",
],
query_parameters: [
"api_key", "apikey", "access_token", "refresh_token", "client_secret", "token",
"signature", "sig", "key", "secret",
],
json_paths: [],
optional_json_paths: [],
}
}
///|
pub fn RedactionPolicy::default() -> RedactionPolicy {
{ request: RedactionConfig::default(), response: RedactionConfig::default(), }
}
///|
/// Apply one configuration to both sides for compatibility with the original
/// MoonVCR API.
pub fn RedactionPolicy::symmetric(config : RedactionConfig) -> RedactionPolicy {
{ request: config, response: config, }
}
///|
/// Errors raised when a configured value cannot be removed safely.
pub(all) enum RedactionError {
InvalidJson(String)
InvalidPath(String)
MissingPath(String)
} derive(Eq, @debug.Debug)
///|
fn sensitive_header(name : String, config : RedactionConfig) -> Bool {
header_name_is_listed(name, RedactionConfig::default().headers) ||
header_name_is_listed(name, config.headers)
}
///|
fn sensitive_query_parameter(name : String, config : RedactionConfig) -> Bool {
let normalized = percent_decode_query_name(name, plus_as_space=true)
.trim()
.to_owned()
.to_lower()
for candidate in RedactionConfig::default().query_parameters {
if normalized ==
percent_decode_query_name(candidate).trim().to_owned().to_lower() {
return true
}
}
for candidate in config.query_parameters {
if normalized ==
percent_decode_query_name(candidate).trim().to_owned().to_lower() {
return true
}
}
false
}
///|
fn hex_digit(value : Int) -> Int? {
if value >= '0'.to_int() && value <= '9'.to_int() {
return Some(value - '0'.to_int())
}
if value >= 'a'.to_int() && value <= 'f'.to_int() {
return Some(value - 'a'.to_int() + 10)
}
if value >= 'A'.to_int() && value <= 'F'.to_int() {
return Some(value - 'A'.to_int() + 10)
}
None
}
///|
/// Decode percent escapes in a query parameter name for sensitive-name
/// matching, exactly once. Literal '+' is form-space only in the URL, not in
/// caller-defined names. Invalid escapes/UTF-8 retain their original spelling.
fn percent_decode_query_name(
name : String,
plus_as_space? : Bool = false,
) -> String {
let input = @utf8.encode(name)
let output : Array[Byte] = []
let mut index = 0
while index < input.length() {
let code = input[index].to_int()
if code == '%'.to_int() && index + 2 < input.length() {
let first = hex_digit(input[index + 1].to_int())
let second = hex_digit(input[index + 2].to_int())
match (first, second) {
(Some(left), Some(right)) => {
output.push((left * 16 + right).to_byte())
index += 3
continue
}
_ => ()
}
}
output.push(
if plus_as_space && code == '+'.to_int() {
(32).to_byte()
} else {
input[index]
},
)
index += 1
}
@utf8.decode(Bytes::from_array(output)) catch {
_ => name
}
}
///|
/// Remove URL user information before query redaction. The host and path are
/// retained, while both usernames and passwords are replaced together.
fn redact_url_userinfo(url : String) -> String {
match url.split_once("://") {
None => url
Some((scheme, rest)) => {
let (location, query) = match rest.split_once("?") {
Some((before, query)) => (before.to_owned(), "?" + query.to_owned())
None => (rest.to_owned(), "")
}
let (authority, path) = match location.split_once("/") {
Some((host, path)) => (host.to_owned(), "/" + path.to_owned())
None => (location, "")
}
match authority.rev_split_once("@") {
Some((_, host)) =>
scheme.to_owned() +
"://" +
REDACTION_MARKER +
"@" +
host.to_owned() +
path +
query
None => url
}
}
}
}
///|
fn redact_headers(
headers : Array[Header],
config : RedactionConfig,
) -> Array[Header] {
headers.map(header => {
if sensitive_header(header.name, config) {
{ name: header.name, value: REDACTION_MARKER, }
} else {
header
}
})
}
///|
/// Replace sensitive query values while preserving the URL's other spelling.
fn redact_url(url : String, config : RedactionConfig) -> String {
let without_fragment = match url.split_once("#") {
Some((before, _)) => before.to_owned()
None => url
}
let without_userinfo = redact_url_userinfo(without_fragment)
match without_userinfo.split_once("?") {
None => without_userinfo
Some((base, raw_query)) => {
let parts : Array[String] = []
let mut changed = false
for raw_view in raw_query.split("&") {
let raw = raw_view.to_owned()
match raw.split_once("=") {
Some((key, value)) =>
if sensitive_query_parameter(key.to_owned(), config) {
parts.push(
key.to_owned().trim().to_owned() + "=" + REDACTION_MARKER,
)
changed = true
} else {
parts.push(key.to_owned() + "=" + value.to_owned())
}
None =>
if sensitive_query_parameter(raw, config) {
parts.push(raw.trim().to_owned() + "=" + REDACTION_MARKER)
changed = true
} else {
parts.push(raw)
}
}
}
let rebuilt = base.to_owned() + "?" + parts.join("&")
if !changed {
return rebuilt
}
rebuilt
}
}
}
///|
fn decode_pointer_token(
raw : StringView,
path : String,
) -> Result[String, RedactionError] {
let output = StringBuilder(size_hint=raw.length())
let mut index = 0
while index < raw.length() {
let character = raw.unsafe_get(index)
if character == '~' {
if index + 1 >= raw.length() {
return Err(InvalidPath(path + ""))
}
let escaped = raw.unsafe_get(index + 1)
match escaped {
'0' => output.write_char('~')
'1' => output.write_char('/')
_ => return Err(InvalidPath(path + ""))
}
index += 2
} else {
output.write_char(character.unsafe_to_char())
index += 1
}
}
Ok(output.to_string())
}
///|
/// Parse JSON Pointer or the documented dot shorthand into object-key tokens.
fn parse_redaction_path(path : String) -> Result[Array[String], RedactionError] {
// JSON Pointer identifies exact object keys. Whitespace in a pointer is
// data, including in its final token; trimming it could select a neighbor.
if path.has_prefix("/") {
let tokens : Array[String] = []
let tail = path[1:].to_owned()
for raw in tail.split("/") {
match decode_pointer_token(raw, path + "") {
Ok(token) => tokens.push(token)
Err(error) => return Err(error)
}
}
return Ok(tokens)
}
// Only the separate dot shorthand accepts surrounding formatting spaces.
let trimmed = path.trim().to_owned()
if trimmed.length() == 0 || trimmed == "$" {
return Err(InvalidPath(path + ""))
}
let shorthand = if trimmed.has_prefix("$.") {
trimmed[2:].to_owned()
} else if trimmed.has_prefix("$") {
return Err(InvalidPath(path + ""))
} else {
trimmed
}
let tokens : Array[String] = []
for raw in shorthand.split(".") {
let token = raw.to_owned().trim().to_owned()
if token.length() == 0 || token.contains_char('/') {
return Err(InvalidPath(path + ""))
}
tokens.push(token)
}
Ok(tokens)
}
///|
fn parse_array_index(token : String) -> Int? {
if token.length() == 0 || (token.length() > 1 && token.has_prefix("0")) {
return None
}
let mut result = 0
for index in 0.. nine {
return None
}
let digit = code - zero
if result > (2147483647 - digit) / 10 {
return None
}
result = result * 10 + digit
}
Some(result)
}
///|
fn redact_json_at(
value : Json,
tokens : Array[String],
depth : Int,
path : String,
) -> Result[Json, RedactionError] {
if depth >= tokens.length() {
return Ok(Json::string(REDACTION_MARKER))
}
let token = tokens[depth] + ""
match value {
Object(members) =>
match members.get(token + "") {
None => Err(MissingPath(path + ""))
Some(child) =>
match redact_json_at(child, tokens, depth + 1, path) {
Ok(updated_child) => {
let updated = members.copy()
updated[token] = updated_child
Ok(Json::object(updated))
}
Err(error) => Err(error)
}
}
Array(items) =>
match parse_array_index(token) {
None => Err(InvalidPath(path + ""))
Some(index) =>
match items.get(index) {
None => Err(MissingPath(path + ""))
Some(child) =>
match redact_json_at(child, tokens, depth + 1, path) {
Ok(updated_child) => {
let updated = items.copy()
updated[index] = updated_child
Ok(Json::array(updated))
}
Err(error) => Err(error)
}
}
}
_ => Err(MissingPath(path + ""))
}
}
///|
priv struct RedactionTarget {
path : String
tokens : Array[String]
required : Bool
}
///|
fn redact_json_text(
text : String,
config : RedactionConfig,
) -> Result[String, RedactionError] {
let original = @json.parse(text) catch {
_ => return Err(InvalidJson("body is not valid JSON text"))
}
let targets : Array[RedactionTarget] = []
for
(required, paths) in [
(true, config.json_paths),
(false, config.optional_json_paths),
] {
for path in paths {
let tokens = match parse_redaction_path(path + "") {
Ok(tokens) => tokens
Err(error) => return Err(error)
}
targets.push({ path: path + "", tokens, required, })
}
}
// A descendant must be processed before an ancestor can replace its whole
// subtree. The secondary lexical order keeps equal-depth outcomes stable.
targets.sort_by((left, right) => {
let depth_order = right.tokens.length().compare(left.tokens.length())
if depth_order != 0 {
depth_order
} else {
left.path.compare(right.path)
}
})
let present : Array[RedactionTarget] = []
// Validate every rule against the untouched document. In particular, an
// ancestor's marker must not hide a missing required or invalid optional
// descendant. redact_json_at creates copies rather than mutating original.
for target in targets {
match redact_json_at(original, target.tokens, 0, target.path) {
Ok(_) => present.push(target)
Err(MissingPath(_)) if !target.required => ()
Err(error) => return Err(error)
}
}
let mut json = original
for target in present {
json = match redact_json_at(json, target.tokens, 0, target.path) {
Ok(updated) => updated
Err(error) => return Err(error)
}
}
Ok(json.stringify())
}
///|
fn redact_body(
body : Body,
config : RedactionConfig,
) -> Result[Body, RedactionError] {
if config.json_paths.is_empty() && config.optional_json_paths.is_empty() {
return Ok(body)
}
// Validate configuration even when the representation cannot contain JSON.
for path in config.json_paths + config.optional_json_paths {
match parse_redaction_path(path) {
Err(error) => return Err(error)
Ok(_) => ()
}
}
match body {
Text(text) =>
match redact_json_text(text, config) {
Ok(value) => Ok(Text(value))
Err(error) => Err(error)
}
Empty | Base64(_) => Err(InvalidJson("JSON redaction requires a Text body"))
}
}
///|
/// Return a request safe to store in a cassette.
pub fn redact_request(
request : Request,
config : RedactionConfig,
) -> Result[Request, RedactionError] {
let { method, url, headers, body, } = request
match redact_body(body, config) {
Ok(safe_body) =>
Ok({
method,
url: redact_url(url, config),
headers: redact_headers(headers, config),
body: safe_body,
})
Err(error) => Err(error)
}
}
///|
/// Return a response safe to store in a cassette.
pub fn redact_response(
response : Response,
config : RedactionConfig,
) -> Result[Response, RedactionError] {
let { status, headers, body, } = response
match redact_body(body, config) {
Ok(safe_body) =>
Ok({ status, headers: redact_headers(headers, config), body: safe_body, })
Err(error) => Err(error)
}
}
///|
/// Redact both sides of an interaction before it is persisted.
pub fn redact_interaction(
interaction : Interaction,
config : RedactionConfig,
) -> Result[Interaction, RedactionError] {
redact_interaction_with_policy(
interaction,
RedactionPolicy::symmetric(config),
)
}
///|
/// Redact a recorded request and response with independent policies.
pub fn redact_interaction_with_policy(
interaction : Interaction,
policy : RedactionPolicy,
) -> Result[Interaction, RedactionError] {
let { request, response, } = interaction
match redact_request(request, policy.request) {
Err(error) => Err(error)
Ok(safe_request) =>
match redact_response(response, policy.response) {
Err(error) => Err(error)
Ok(safe_response) =>
Ok({ request: safe_request, response: safe_response, })
}
}
}