use core::fmt;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Map, Value};
use crate::fetch::{self, FetchError};
use crate::terminal_safe::sanitise;
pub const DEFAULT_INDEX_URL: &str = "https://shep-pm.com/dogs.json";
pub const INDEX_URL_ENV: &str = "SHEP_DOG_INDEX";
const CATEGORIES: [&str; 6] = ["logs", "metrics", "alerts", "health", "deploy", "other"];
const SUPPORTED_INDEX_VERSION: u64 = 1;
const SIZE_LIMIT: usize = 1 << 20;
const TIMEOUT: Duration = Duration::from_secs(10);
const LOOPBACK_HOSTS: [&str; 4] = ["localhost", "127.0.0.1", "::1", "[::1]"];
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AvailableDog {
pub name: String,
pub package: String,
pub adopt_as: String,
pub description: String,
pub repo: String,
pub license: String,
pub category: String,
pub source: DogSourceKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DogSourceKind {
Cargo {
version: Option<String>,
},
CargoGit {
url: String,
},
GoInstall {
module: String,
},
Manual {
instructions: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Index {
pub dogs: Vec<AvailableDog>,
pub skipped: usize,
pub sanitised: usize,
}
#[derive(Debug)]
pub enum IndexError {
InsecureUrl(String),
Fetch(FetchError),
Malformed(String),
NotAnObject,
UnsupportedVersion {
found: Option<Value>,
},
MissingDogsArray,
}
impl fmt::Display for IndexError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InsecureUrl(url) => write!(
f,
"dog index url {url} is not https://; the index is read over TLS \
unless it is served from loopback"
),
Self::Fetch(source) => write!(f, "{source}"),
Self::Malformed(reason) => write!(f, "the dog index was not valid json: {reason}"),
Self::NotAnObject => write!(
f,
"the dog index was not a json object -- a bare array is the shape shep 0.1.0 \
read, and is no longer accepted"
),
Self::UnsupportedVersion { found } => {
let found = found
.as_ref()
.map_or_else(|| "unspecified".to_string(), Value::to_string);
write!(
f,
"the dog index is version {found}, which this shep does not understand \
(this build reads version {SUPPORTED_INDEX_VERSION}); upgrade shep to read it"
)
}
Self::MissingDogsArray => write!(f, "the dog index has no \"dogs\" array"),
}
}
}
impl core::error::Error for IndexError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Fetch(source) => Some(source),
Self::InsecureUrl(_)
| Self::Malformed(_)
| Self::NotAnObject
| Self::UnsupportedVersion { .. }
| Self::MissingDogsArray => None,
}
}
}
impl From<FetchError> for IndexError {
fn from(source: FetchError) -> Self {
Self::Fetch(source)
}
}
pub fn index_url() -> String {
std::env::var(INDEX_URL_ENV).unwrap_or_else(|_err| DEFAULT_INDEX_URL.to_owned())
}
pub async fn fetch_index(url: &str) -> Result<Index, IndexError> {
let target = fetch::parse_url(url)?;
require_secure_url(url, &target)?;
let bytes = fetch::get(&target, SIZE_LIMIT, TIMEOUT).await?;
parse_index(&bytes)
}
fn require_secure_url(url: &str, target: &fetch::Target) -> Result<(), IndexError> {
if target.https || LOOPBACK_HOSTS.contains(&target.host.as_str()) {
Ok(())
} else {
Err(IndexError::InsecureUrl(url.to_owned()))
}
}
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)?;
if !CATEGORIES.contains(&category.as_str()) {
return None;
}
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)
}
fn is_https(url: &str) -> bool {
url.starts_with("https://")
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::*;
fn workspace_web_dir() -> Option<PathBuf> {
let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../web"));
dir.is_dir().then(|| dir.to_path_buf())
}
fn read_workspace_web_file(relative: &str) -> Option<String> {
let dir = workspace_web_dir()?;
Some(
std::fs::read_to_string(dir.join(relative)).unwrap_or_else(|err| {
panic!("web/{relative} exists in the workspace but could not be read: {err}")
}),
)
}
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])
}
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)
}
async fn serve_index(body: String) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
tokio::spawn(async move {
let (mut stream, _peer) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf).await;
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
format!("http://127.0.0.1:{}/dogs.json", addr.port())
}
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_skipped_rather_than_shown() {
let index = parse_index(one_entry_with_category("logz").as_bytes()).expect("parses");
assert_eq!(index.dogs.len(), 0);
assert_eq!(index.skipped, 1);
}
#[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["category"] = serde_json::Value::String("logz".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);
}
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"
);
}
}
#[test]
fn the_source_kinds_match_the_docs_site_list() {
let Some(dogs_ts) = read_workspace_web_file("src/data/dogs.ts") else {
return;
};
let after = dogs_ts
.split_once("const SOURCE_KINDS")
.expect("web/src/data/dogs.ts declares SOURCE_KINDS")
.1
.split_once('=')
.expect("the SOURCE_KINDS declaration has an initialiser")
.1;
let literal = after
.split_once("];")
.expect("the SOURCE_KINDS array is closed")
.0;
let site: Vec<&str> = literal.split('"').skip(1).step_by(2).collect();
let ours: Vec<&str> = SOURCE_KINDS.iter().map(|(kind, _)| *kind).collect();
assert_eq!(
site, ours,
"web/src/data/dogs.ts and dog_index.rs disagree about the source kinds"
);
}
#[test]
fn the_categories_match_the_docs_site_list() {
let Some(dogs_ts) = read_workspace_web_file("src/data/dogs.ts") else {
return;
};
let after = dogs_ts
.split_once("export const CATEGORIES")
.expect("web/src/data/dogs.ts declares CATEGORIES")
.1;
let literal = after
.split_once("];")
.expect("the CATEGORIES array is closed")
.0;
let site: Vec<&str> = literal.split('"').skip(1).step_by(2).collect();
assert_eq!(
site,
CATEGORIES.to_vec(),
"web/src/data/dogs.ts and dog_index.rs disagree about the categories"
);
}
#[test]
fn the_schema_agrees_with_the_categories_and_source_kinds() {
let Some(schema) = read_workspace_web_file("public/dogs.schema.json") else {
return;
};
let schema: Value = serde_json::from_str(&schema).expect("dogs.schema.json is valid json");
let entry_schema = &schema["properties"]["dogs"]["items"];
let schema_categories: Vec<&str> = entry_schema["properties"]["category"]["enum"]
.as_array()
.expect("category.enum is an array")
.iter()
.map(|v| v.as_str().expect("each category is a string"))
.collect();
assert_eq!(
schema_categories,
CATEGORIES.to_vec(),
"dogs.schema.json and dog_index.rs disagree about the categories"
);
let schema_kinds: Vec<&str> = entry_schema["properties"]["source"]["oneOf"]
.as_array()
.expect("source.oneOf is an array")
.iter()
.map(|variant| {
variant["properties"]["kind"]["const"]
.as_str()
.expect("each source variant names a const kind")
})
.collect();
let ours: Vec<&str> = SOURCE_KINDS.iter().map(|(kind, _)| *kind).collect();
assert_eq!(
schema_kinds, ours,
"dogs.schema.json and dog_index.rs disagree about the source kinds"
);
}
#[test]
fn the_default_index_url_is_https() {
assert!(
DEFAULT_INDEX_URL.starts_with("https://"),
"{DEFAULT_INDEX_URL}"
);
}
#[tokio::test]
async fn a_plain_http_index_url_is_refused_before_it_connects() {
let err = fetch_index("http://example.com/dogs.json")
.await
.expect_err("refused");
let IndexError::InsecureUrl(url) = err else {
panic!("wrong variant: {err:?}")
};
assert_eq!(url, "http://example.com/dogs.json");
}
#[tokio::test]
async fn a_host_that_merely_contains_a_loopback_literal_is_still_refused() {
for url in [
"http://127.0.0.1.example.com/dogs.json",
"http://evil.com@127.0.0.1/dogs.json",
"http://localhost.example.com/dogs.json",
] {
assert!(
matches!(fetch_index(url).await, Err(IndexError::InsecureUrl(_))),
"{url} was not refused"
);
}
}
#[tokio::test]
async fn a_loopback_http_index_is_read_because_that_is_how_a_local_one_is_served() {
let url = serve_index(one_entry_with_description("clean\u{1b}[2Jhere")).await;
let index = fetch_index(&url).await.expect("read");
assert_eq!(index.dogs.len(), 1);
assert_eq!(index.sanitised, 1);
assert!(!index.dogs[0].description.contains('\u{1b}'));
}
}