use serde_json::{Map, Value};
use super::*;
fn version_is_supported(version: &Value) -> bool {
version.as_u64() == Some(SUPPORTED_INDEX_VERSION)
|| version.as_f64() == Some(SUPPORTED_INDEX_VERSION as f64)
}
pub fn parse_index(bytes: &[u8]) -> Result<Index, IndexError> {
let document: Value =
serde_json::from_slice(bytes).map_err(|err| IndexError::Malformed(err.to_string()))?;
let Value::Object(document) = document else {
return Err(IndexError::NotAnObject);
};
if !document.get("version").is_some_and(version_is_supported) {
return Err(IndexError::UnsupportedVersion {
found: document.get("version").cloned(),
});
}
let Some(entries) = document.get("dogs").and_then(Value::as_array) else {
return Err(IndexError::MissingDogsArray);
};
let mut dogs = Vec::with_capacity(entries.len());
let mut skipped = 0;
let mut sanitised = 0;
for entry in entries {
let mut entry_sanitised = false;
match validate_entry(entry, &mut entry_sanitised) {
Some(dog) => {
if entry_sanitised {
sanitised += 1;
}
dogs.push(dog);
}
None => skipped += 1,
}
}
Ok(Index {
dogs,
skipped,
sanitised,
})
}
fn validate_entry(entry: &Value, sanitised: &mut bool) -> Option<AvailableDog> {
let entry = entry.as_object()?;
let name = field(entry, "name", sanitised)?;
let package = field(entry, "package", sanitised)?;
let adopt_as = field(entry, "adopt_as", sanitised)?;
let description = field(entry, "description", sanitised)?;
let repo = field(entry, "repo", sanitised)?;
let license = field(entry, "license", sanitised)?;
let category = field(entry, "category", sanitised)?;
let category = if CATEGORIES.contains(&category.as_str()) {
category
} else {
UNKNOWN_CATEGORY_FALLBACK.to_owned()
};
if !is_https(&repo) {
return None;
}
let source = validate_source(entry.get("source")?, sanitised)?;
Some(AvailableDog {
name,
package,
adopt_as,
description,
repo,
license,
category,
source,
})
}
fn validate_source(source: &Value, sanitised: &mut bool) -> Option<DogSourceKind> {
let source = source.as_object()?;
match source.get("kind")?.as_str()? {
"cargo" => {
let version = match source.get("version") {
None => None,
Some(_) => Some(field(source, "version", sanitised)?),
};
Some(DogSourceKind::Cargo { version })
}
"cargo-git" => {
let url = field(source, "url", sanitised)?;
if !is_https(&url) {
return None;
}
Some(DogSourceKind::CargoGit { url })
}
"go-install" => Some(DogSourceKind::GoInstall {
module: field(source, "module", sanitised)?,
}),
"manual" => Some(DogSourceKind::Manual {
instructions: field(source, "instructions", sanitised)?,
}),
_ => None,
}
}
fn field(object: &Map<String, Value>, name: &str, sanitised: &mut bool) -> Option<String> {
let raw = object.get(name)?.as_str()?;
let (clean, changed) = sanitise(raw);
*sanitised |= changed;
if clean.is_empty() {
return None;
}
Some(clean)
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
fn valid_entry() -> serde_json::Value {
serde_json::json!({
"name": "Spot",
"package": "shep-log-rotate",
"adopt_as": "log-rotate",
"description": "Rotates grown log files and asks the shepherd to reopen them.",
"repo": "https://github.com/shep-pm/shep-log-rotate",
"license": "MIT OR Apache-2.0",
"category": "logs",
"source": {
"kind": "cargo-git",
"url": "https://github.com/shep-pm/shep-log-rotate"
}
})
}
fn wrap_index(entries: Vec<Value>) -> String {
serde_json::json!({
"$schema": "https://shep-pm.com/dogs.schema.json",
"version": SUPPORTED_INDEX_VERSION,
"dogs": entries,
})
.to_string()
}
fn one_entry_with(field: &str, value: &str) -> String {
let mut entry = valid_entry();
entry[field] = serde_json::Value::String(value.to_string());
wrap_index(vec![entry])
}
pub(crate) fn one_entry_with_description(description: &str) -> String {
one_entry_with("description", description)
}
fn one_entry_with_category(category: &str) -> String {
one_entry_with("category", category)
}
fn one_entry_with_repo(repo: &str) -> String {
one_entry_with("repo", repo)
}
const THREE_ENTRIES_MIDDLE_BROKEN: &[u8] = br#"{
"$schema": "https://shep-pm.com/dogs.schema.json",
"version": 1,
"dogs": [
{
"name": "Spot",
"package": "shep-log-rotate",
"adopt_as": "log-rotate",
"description": "Rotates grown log files.",
"repo": "https://github.com/shep-pm/shep-log-rotate",
"license": "MIT OR Apache-2.0",
"category": "logs",
"source": { "kind": "cargo-git", "url": "https://github.com/shep-pm/shep-log-rotate" }
},
{
"name": "Nameless",
"package": "shep-nameless",
"description": "Has no adopt_as, so nobody could adopt it correctly.",
"repo": "https://github.com/example/shep-nameless",
"license": "MIT",
"category": "other",
"source": { "kind": "manual", "instructions": "Build it yourself." }
},
{
"name": "Rex",
"package": "shep-watchdog",
"adopt_as": "watchdog",
"description": "Barks when a sheep stops answering.",
"repo": "https://github.com/example/shep-watchdog",
"license": "Apache-2.0",
"category": "health",
"source": { "kind": "go-install", "module": "github.com/example/shep-watchdog" }
}
]}"#;
#[test]
fn a_sanitised_entry_still_lists_and_is_counted() {
let index = parse_index(one_entry_with_description("clean\u{1b}[2Jhere").as_bytes())
.expect("parses");
assert_eq!(
index.dogs.len(),
1,
"a hostile description does not remove the dog"
);
assert_eq!(index.sanitised, 1);
assert!(!index.dogs[0].description.contains('\u{1b}'));
}
#[test]
fn a_malformed_entry_is_skipped_and_counted_while_its_neighbours_list() {
let index = parse_index(THREE_ENTRIES_MIDDLE_BROKEN).expect("parses");
assert_eq!(index.dogs.len(), 2);
assert_eq!(index.skipped, 1);
}
#[test]
fn an_unknown_category_is_filed_under_the_fallback_rather_than_dropped() {
let index = parse_index(one_entry_with_category("logz").as_bytes()).expect("parses");
assert_eq!(
index.dogs.len(),
1,
"an unknown category must not drop the entry"
);
assert_eq!(index.dogs[0].category, UNKNOWN_CATEGORY_FALLBACK);
assert_eq!(
index.skipped, 0,
"a listed entry is never also counted skipped"
);
}
#[test]
fn a_known_category_survives_unchanged() {
let index = parse_index(one_entry_with_category("logs").as_bytes()).expect("parses");
assert_eq!(index.dogs[0].category, "logs");
assert_eq!(index.skipped, 0);
}
#[test]
fn a_non_https_repo_is_skipped() {
let index =
parse_index(one_entry_with_repo("http://example.com/x").as_bytes()).expect("parses");
assert_eq!(index.skipped, 1);
}
#[test]
fn the_old_bare_array_format_is_refused() {
let bare = serde_json::Value::Array(vec![valid_entry()]).to_string();
assert!(
matches!(parse_index(bare.as_bytes()), Err(IndexError::NotAnObject)),
"a bare array must be refused now, not silently accepted"
);
}
#[test]
fn an_object_with_no_version_is_unsupported_not_malformed() {
let err = parse_index(b"{}").expect_err("no version field");
assert!(
matches!(err, IndexError::UnsupportedVersion { found: None }),
"{err:?}"
);
assert!(
err.to_string().contains("unspecified"),
"the message must say no version was given: {err}"
);
}
#[test]
fn a_version_this_build_does_not_understand_is_refused_with_an_upgrade_message() {
let document = serde_json::json!({ "version": 99, "dogs": [] }).to_string();
let err = parse_index(document.as_bytes()).expect_err("unsupported version");
let IndexError::UnsupportedVersion { found } = &err else {
panic!("wrong variant: {err:?}");
};
assert_eq!(found.as_ref().and_then(serde_json::Value::as_u64), Some(99));
let message = err.to_string();
assert!(message.contains("99"), "{message}");
assert!(message.contains("upgrade"), "{message}");
}
#[test]
fn a_version_spelled_with_a_decimal_point_is_still_supported() {
let as_decimal = SUPPORTED_INDEX_VERSION as f64;
let document = serde_json::json!({ "version": as_decimal, "dogs": [] }).to_string();
let index = parse_index(document.as_bytes())
.expect("a decimal-point spelling is the same number as SUPPORTED_INDEX_VERSION");
assert!(index.dogs.is_empty());
}
#[test]
fn a_supported_version_with_no_dogs_field_is_refused() {
let document = serde_json::json!({ "version": SUPPORTED_INDEX_VERSION }).to_string();
assert!(matches!(
parse_index(document.as_bytes()),
Err(IndexError::MissingDogsArray)
));
}
#[test]
fn an_empty_dogs_array_is_a_valid_empty_index() {
let index = parse_index(wrap_index(vec![]).as_bytes()).expect("parses");
assert!(index.dogs.is_empty());
assert_eq!(index.skipped, 0);
}
#[test]
fn an_escape_split_across_a_field_boundary_cannot_reassemble() {
let mut entry = valid_entry();
entry["name"] = serde_json::Value::String("Spot\u{1b}".to_string());
entry["description"] = serde_json::Value::String("[2J and the screen is gone".to_string());
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 1);
let dog = &index.dogs[0];
let joined = format!("{}{}", dog.name, dog.description);
assert!(!joined.contains('\u{1b}'), "reassembled in {joined:?}");
assert_eq!(
index.sanitised, 1,
"counted once for the entry, not per field"
);
}
#[test]
fn a_long_run_of_escapes_is_stripped_without_losing_the_entry() {
let hostile = format!("{}real text", "\u{1b}".repeat(10_000));
let index = parse_index(one_entry_with_description(&hostile).as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 1);
assert_eq!(index.dogs[0].description, "real text");
assert_eq!(index.sanitised, 1);
}
#[test]
fn a_field_that_is_nothing_but_control_characters_skips_the_entry() {
let index = parse_index(one_entry_with_description("\u{1b}\u{7}\r\n\t").as_bytes())
.expect("parses");
assert_eq!(index.dogs.len(), 0);
assert_eq!(index.skipped, 1);
}
#[test]
fn a_skipped_entry_is_not_also_counted_as_sanitised() {
let mut entry = valid_entry();
entry["description"] = serde_json::Value::String("hostile\u{1b}[2J".to_string());
entry["repo"] = serde_json::Value::String("http://example.com/x".to_string());
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.skipped, 1);
assert_eq!(index.sanitised, 0);
}
#[test]
fn a_field_of_the_wrong_json_type_skips_only_its_own_entry() {
let mut broken = valid_entry();
broken["name"] = serde_json::json!(42);
let mut other = valid_entry();
other["package"] = serde_json::Value::String("shep-watchdog".to_string());
let document = wrap_index(vec![broken, other]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 1);
assert_eq!(index.skipped, 1);
assert_eq!(index.dogs[0].package, "shep-watchdog");
}
#[test]
fn a_cargo_source_parses_and_carries_no_fields_of_its_own() {
let mut entry = valid_entry();
entry["source"] = serde_json::json!({ "kind": "cargo" });
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.skipped, 0);
assert_eq!(index.dogs.len(), 1);
assert_eq!(index.dogs[0].source, DogSourceKind::Cargo { version: None });
}
#[test]
fn a_non_https_cargo_git_source_url_is_skipped() {
let mut entry = valid_entry();
entry["source"] = serde_json::json!({ "kind": "cargo-git", "url": "http://example.com/x" });
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 0);
assert_eq!(index.skipped, 1);
}
#[test]
fn an_unknown_source_kind_is_skipped() {
let mut entry = valid_entry();
entry["source"] =
serde_json::json!({ "kind": "curl-bash", "url": "https://example.com/x" });
let document = wrap_index(vec![entry]);
assert_eq!(parse_index(document.as_bytes()).expect("parses").skipped, 1);
}
pub(crate) const SOURCE_KINDS: [(&str, &str); 4] = [
("cargo", r#"{"kind":"cargo"}"#),
(
"cargo-git",
r#"{"kind":"cargo-git","url":"https://example.com/x"}"#,
),
(
"go-install",
r#"{"kind":"go-install","module":"example.com/x"}"#,
),
("manual", r#"{"kind":"manual","instructions":"build it"}"#),
];
#[test]
fn a_cargo_source_keeps_the_version_it_names() {
let mut entry = valid_entry();
entry["source"] = serde_json::json!({ "kind": "cargo", "version": "0.1.0-alpha.1" });
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(
index.dogs[0].source,
DogSourceKind::Cargo {
version: Some("0.1.0-alpha.1".to_string())
}
);
}
#[test]
fn a_cargo_source_with_an_empty_version_is_skipped() {
let mut entry = valid_entry();
entry["source"] = serde_json::json!({ "kind": "cargo", "version": "" });
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 0);
assert_eq!(index.skipped, 1);
}
#[test]
fn every_listed_source_kind_actually_parses() {
for (kind, source) in SOURCE_KINDS {
let mut entry = valid_entry();
entry["source"] = serde_json::from_str(source).expect("fixture is JSON");
let document = wrap_index(vec![entry]);
let index = parse_index(document.as_bytes()).expect("parses");
assert_eq!(
index.dogs.len(),
1,
"source.kind {kind:?} is listed as supported but its entry was skipped"
);
}
}
}