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.turtlesocks.dev/dogs.json";
pub const INDEX_URL_ENV: &str = "SHEP_DOG_INDEX";
const CATEGORIES: [&str; 6] = ["logs", "metrics", "alerts", "health", "deploy", "other"];
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),
NotAnArray,
}
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::NotAnArray => write!(f, "the dog index was not a json 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::NotAnArray => 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()))
}
}
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::Array(entries) = document else {
return Err(IndexError::NotAnArray);
};
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 tokio::io::{AsyncReadExt, AsyncWriteExt};
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/TurtIeSocks/shep-log-rotate",
"license": "MIT OR Apache-2.0",
"category": "logs",
"source": {
"kind": "cargo-git",
"url": "https://github.com/TurtIeSocks/shep-log-rotate"
}
})
}
fn one_entry_with(field: &str, value: &str) -> String {
let mut entry = valid_entry();
entry[field] = serde_json::Value::String(value.to_string());
serde_json::Value::Array(vec![entry]).to_string()
}
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#"[
{
"name": "Spot",
"package": "shep-log-rotate",
"adopt_as": "log-rotate",
"description": "Rotates grown log files.",
"repo": "https://github.com/TurtIeSocks/shep-log-rotate",
"license": "MIT OR Apache-2.0",
"category": "logs",
"source": { "kind": "cargo-git", "url": "https://github.com/TurtIeSocks/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 a_document_that_is_not_an_array_is_an_error_not_an_empty_list() {
assert!(parse_index(b"{}").is_err());
}
#[test]
fn an_empty_array_is_a_valid_empty_index() {
let index = parse_index(b"[]").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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![broken, other]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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 = serde_json::Value::Array(vec![entry]).to_string();
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() {
const DOGS_TS: &str = include_str!("../../../web/src/data/dogs.ts");
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() {
const DOGS_TS: &str = include_str!("../../../web/src/data/dogs.ts");
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_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}'));
}
}