use serde_json::{Map, Value};
use super::{FieldKind, ListItem, ValueKind};
fn resolve<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
schema
.get("$ref")
.and_then(Value::as_str)
.and_then(|r| r.strip_prefix("#/$defs/"))
.and_then(|name| defs.get(name))
.unwrap_or(schema)
}
fn ref_name(schema: &Value) -> Option<&str> {
let target = schema.get("$ref").or_else(|| {
schema
.get("anyOf")?
.as_array()?
.iter()
.find_map(|arm| arm.get("$ref"))
})?;
target.as_str()?.strip_prefix("#/$defs/")
}
pub(super) fn value_kind_of(schema: &Value) -> Option<ValueKind> {
match ref_name(schema)? {
"MemSize" => Some(ValueKind::MemSize),
"UpDuration" => Some(ValueKind::UpDuration),
_ => None,
}
}
fn strip_nullable<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
let Some(arms) = schema.get("anyOf").and_then(Value::as_array) else {
return schema;
};
let non_null: Vec<&Value> = arms
.iter()
.filter(|arm| arm.get("type").and_then(Value::as_str) != Some("null"))
.collect();
match non_null.as_slice() {
[one] => resolve(one, defs),
_ => schema,
}
}
pub(super) fn resolved<'a>(schema: &'a Value, defs: &'a Map<String, Value>) -> &'a Value {
strip_nullable(resolve(schema, defs), defs)
}
fn items(schema: &Value) -> &Value {
schema.get("items").unwrap_or(&Value::Null)
}
fn type_of(schema: &Value) -> Option<&str> {
match schema.get("type")? {
Value::String(s) => Some(s.as_str()),
Value::Array(arr) => arr.iter().filter_map(Value::as_str).find(|t| *t != "null"),
_ => None,
}
}
pub(super) fn kind_of(schema: &Value, defs: &Map<String, Value>) -> FieldKind {
let schema = resolved(schema, defs);
if let Some(consts) = schema.get("oneOf").and_then(Value::as_array) {
let names: Vec<String> = consts
.iter()
.filter_map(|arm| arm.get("const").and_then(Value::as_str))
.map(str::to_owned)
.collect();
if !names.is_empty() && names.len() == consts.len() {
return FieldKind::Choice(names);
}
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
let names: Vec<String> = values
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
if names.len() == values.len() {
return FieldKind::Choice(names);
}
}
match type_of(schema) {
Some("boolean") => FieldKind::Bool,
Some("integer") => FieldKind::Integer,
Some("string") => FieldKind::Text,
Some("array") => match type_of(strip_nullable(resolve(items(schema), defs), defs)) {
Some("string") => FieldKind::List(ListItem::Text),
Some("integer") => FieldKind::List(ListItem::Integer),
_ => FieldKind::Opaque,
},
Some("object")
if schema.get("additionalProperties").is_some()
&& schema.get("properties").is_none() =>
{
match schema
.get("additionalProperties")
.map(|additional| resolve(additional, defs))
.and_then(type_of)
{
Some("object") => FieldKind::Opaque,
_ => FieldKind::Map,
}
}
_ => FieldKind::Opaque,
}
}
pub(super) fn render_default(value: Option<&Value>) -> Option<String> {
match value? {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
other => Some(other.to_string()),
}
}
pub(super) fn marked(schema: &Value) -> bool {
schema
.get(shep_core::dogs::SECRET_KEY)
.and_then(Value::as_bool)
== Some(true)
}
pub(super) fn holds_secret(schema: &Value, defs: &Map<String, Value>) -> bool {
reaches_secret(schema, defs, &mut Vec::new())
}
fn reaches_secret<'a>(
schema: &'a Value,
defs: &'a Map<String, Value>,
seen: &mut Vec<&'a str>,
) -> bool {
match schema {
Value::Object(map) => {
if marked(schema) {
return true;
}
let target = map
.get("$ref")
.and_then(Value::as_str)
.and_then(|r| r.strip_prefix("#/$defs/"));
if let Some(name) = target
&& !seen.contains(&name)
{
seen.push(name);
if defs
.get(name)
.is_some_and(|def| reaches_secret(def, defs, seen))
{
return true;
}
}
map.values().any(|value| reaches_secret(value, defs, seen))
}
Value::Array(items) => items.iter().any(|item| reaches_secret(item, defs, seen)),
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::super::FieldSet;
use super::super::fixtures::{props, real_field_set};
use super::*;
use serde_json::json;
#[test]
fn holds_secret_finds_the_mark_wherever_the_value_reaches() {
let defs = props(json!({
"Token": { "type": "string", "x-shep-secret": true },
"Creds": { "type": "object", "properties": { "token": { "$ref": "#/$defs/Token" } } },
"Node": { "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } },
"Plain": { "type": "object" },
}));
let secret = |schema: Value| holds_secret(&schema, &defs);
assert!(secret(json!({ "type": "string", "x-shep-secret": true })));
assert!(
secret(json!({
"type": "object",
"properties": { "key": { "type": "string", "x-shep-secret": true } },
})),
"inline, no $ref"
);
assert!(secret(json!({ "$ref": "#/$defs/Token" })));
assert!(secret(json!({ "$ref": "#/$defs/Creds" })), "two hops");
assert!(
secret(json!({
"$ref": "#/$defs/Plain",
"properties": { "key": { "type": "string", "x-shep-secret": true } },
})),
"a plain $ref does not end the walk"
);
assert!(secret(
json!({ "type": "array", "items": { "$ref": "#/$defs/Creds" } })
));
assert!(!secret(json!({ "type": "string" })));
assert!(!secret(json!({ "$ref": "#/$defs/Node" })), "a loop ends");
assert!(!secret(json!({ "$ref": "#/$defs/Missing" })));
}
#[test]
fn a_bool_an_integer_and_a_string_get_their_kinds() {
let p = props(json!({
"watch": { "type": "boolean", "default": false },
"max_restarts": { "type": "integer", "format": "uint32", "default": 16 },
"cwd": { "type": ["string", "null"], "default": null },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("watch").unwrap().kind, FieldKind::Bool);
assert_eq!(set.by_key("max_restarts").unwrap().kind, FieldKind::Integer);
assert_eq!(set.by_key("cwd").unwrap().kind, FieldKind::Text);
}
#[test]
fn a_ref_into_defs_takes_the_named_types_kind() {
let p = props(json!({
"kill_timeout": { "$ref": "#/$defs/UpDuration", "default": "1600" },
}));
let d = props(json!({
"UpDuration": { "type": "string", "pattern": "^\\d+(ms|h|m|s)?$" },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("kill_timeout").unwrap().kind, FieldKind::Text);
assert_eq!(
set.by_key("kill_timeout").unwrap().default.as_deref(),
Some("1600")
);
}
#[test]
fn any_of_with_null_is_the_other_arm() {
let p = props(json!({
"max_memory": {
"anyOf": [{ "$ref": "#/$defs/MemSize" }, { "type": "null" }],
"default": null,
},
}));
let d = props(json!({ "MemSize": { "type": "string" } }));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("max_memory").unwrap().kind, FieldKind::Text);
assert_eq!(set.by_key("max_memory").unwrap().default, None);
}
#[test]
fn one_of_consts_is_a_choice_in_schema_order() {
let p = props(json!({
"kind": {
"oneOf": [
{ "type": "string", "const": "http" },
{ "type": "string", "const": "tcp" },
{ "type": "string", "const": "exec" },
],
},
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(
set.by_key("kind").unwrap().kind,
FieldKind::Choice(vec!["http".into(), "tcp".into(), "exec".into()])
);
}
#[test]
fn a_text_field_records_which_shep_core_grammar_it_holds() {
let d = props(json!({
"MemSize": { "type": "string" },
"UpDuration": { "type": "string" },
}));
let p = props(json!({
"max_memory": {
"anyOf": [{ "$ref": "#/$defs/MemSize" }, { "type": "null" }],
},
"kill_timeout": { "$ref": "#/$defs/UpDuration" },
"cwd": { "type": "string" },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(
set.by_key("max_memory").unwrap().value_kind,
Some(ValueKind::MemSize)
);
assert_eq!(
set.by_key("kill_timeout").unwrap().value_kind,
Some(ValueKind::UpDuration)
);
assert_eq!(set.by_key("cwd").unwrap().value_kind, None);
}
#[test]
fn a_string_map_is_a_map_and_a_nested_object_is_opaque() {
let p = props(json!({
"env": { "type": "object", "additionalProperties": { "type": "string" } },
"liveness_probe": {
"anyOf": [{ "$ref": "#/$defs/ProbeConfig" }, { "type": "null" }],
},
}));
let d = props(json!({
"ProbeConfig": { "type": "object", "properties": { "kind": {} } },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("env").unwrap().kind, FieldKind::Map);
assert_eq!(
set.by_key("liveness_probe").unwrap().kind,
FieldKind::Opaque
);
assert!(!set.by_key("liveness_probe").unwrap().editable);
}
#[test]
fn a_map_of_tables_is_opaque_not_a_string_map() {
let p = props(json!({
"dogs": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/DogTable" },
},
}));
let d = props(json!({
"DogTable": { "type": "object", "additionalProperties": true },
}));
let set = FieldSet::from_properties(&p, &d, &[]);
assert_eq!(set.by_key("dogs").unwrap().kind, FieldKind::Opaque);
assert!(!set.by_key("dogs").unwrap().editable);
}
#[test]
fn a_default_is_rendered_the_way_the_pane_will_show_it() {
let p = props(json!({
"b": { "type": "boolean", "default": true },
"n": { "type": "integer", "default": 16 },
"s": { "type": "string", "default": "1s" },
"l": { "type": "array", "default": [], "items": { "type": "string" } },
}));
let set = FieldSet::from_properties(&p, &Default::default(), &[]);
assert_eq!(set.by_key("b").unwrap().default.as_deref(), Some("true"));
assert_eq!(set.by_key("n").unwrap().default.as_deref(), Some("16"));
assert_eq!(set.by_key("s").unwrap().default.as_deref(), Some("1s"));
assert_eq!(set.by_key("l").unwrap().default.as_deref(), Some("[]"));
}
#[test]
fn the_real_flockfile_schema_marks_every_mem_size_and_up_duration_field() {
let set = real_field_set();
assert_eq!(
set.by_key("max_memory").unwrap().value_kind,
Some(ValueKind::MemSize)
);
for key in [
"kill_timeout",
"listen_timeout",
"min_uptime",
"graceful_timeout",
"action_timeout",
"restart_delay",
"watch_delay",
"exp_backoff_restart_delay",
] {
assert_eq!(
set.by_key(key).unwrap().value_kind,
Some(ValueKind::UpDuration),
"{key}"
);
}
assert_eq!(set.by_key("cwd").unwrap().value_kind, None);
}
#[test]
fn an_array_of_strings_is_a_list_and_an_array_of_integers_knows_its_item() {
let set = real_field_set();
assert_eq!(
set.by_key("args").map(|f| f.kind.clone()),
Some(FieldKind::List(ListItem::Text))
);
assert_eq!(
set.by_key("stop_exit_codes").map(|f| f.kind.clone()),
Some(FieldKind::List(ListItem::Integer))
);
assert!(
set.by_key("args").is_some_and(|f| f.editable),
"an array is editable now"
);
}
}