use core::fmt;
use std::collections::BTreeMap;
use std::time::Duration;
use serde::Serialize;
use serde_json::Value;
use crate::fetch::{self, FetchError};
use crate::terminal_safe::sanitise;
mod cache;
mod parse;
mod prebuilt;
#[cfg(test)]
pub(crate) use cache::write_cache;
pub use cache::{LoadedIndex, Origin, cached_index, cached_only, describe_age};
pub use parse::parse_index;
pub use prebuilt::{BUILD_TARGET, Prebuilt};
pub const DEFAULT_INDEX_URL: &str = "https://shep-pm.com/dogs.json";
pub const INDEX_URL_ENV: &str = "SHEP_DOG_INDEX";
const UNKNOWN_CATEGORY_FALLBACK: &str = "other";
const CATEGORIES: [&str; 7] = [
"logs",
"metrics",
"alerts",
"health",
"deploy",
"interactive",
UNKNOWN_CATEGORY_FALLBACK,
];
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>,
sha256: BTreeMap<String, 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())
}
async fn fetch_index_document(url: &str) -> Result<Vec<u8>, IndexError> {
let target = fetch::parse_url(url)?;
require_secure_url(url, &target)?;
Ok(fetch::get(&target, SIZE_LIMIT, TIMEOUT).await?)
}
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 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::*;
use crate::dog_index::parse::tests::{SOURCE_KINDS, one_entry_with_description};
fn workspace_web_dir() -> Option<PathBuf> {
let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../web"));
dir.is_dir().then(|| dir.to_path_buf())
}
#[track_caller]
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}")
}),
)
}
#[track_caller]
fn docs_site_array<'a>(dogs_ts: &'a str, declaration: &str) -> Vec<&'a str> {
let after = dogs_ts
.split_once(declaration)
.unwrap_or_else(|| panic!("web/src/data/dogs.ts declares {declaration}"))
.1
.split_once('=')
.unwrap_or_else(|| panic!("the {declaration} declaration has an initialiser"))
.1;
let literal = after
.split_once(']')
.unwrap_or_else(|| panic!("the {declaration} array is closed"))
.0;
literal.split('"').skip(1).step_by(2).collect()
}
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())
}
#[test]
fn the_unknown_category_fallback_is_itself_a_known_category() {
assert!(
CATEGORIES.contains(&UNKNOWN_CATEGORY_FALLBACK),
"the fallback must be a category the index groups under"
);
}
#[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 site = docs_site_array(&dogs_ts, "SOURCE_KINDS");
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 site = docs_site_array(&dogs_ts, "CATEGORIES");
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_document("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://localhost.example.com/dogs.json",
] {
assert!(
matches!(
fetch_index_document(url).await,
Err(IndexError::InsecureUrl(_))
),
"{url} was not refused"
);
}
}
#[tokio::test]
async fn a_loopback_literal_behind_a_userinfo_prefix_is_refused_at_parse_time() {
let err = fetch_index_document("http://evil.com@127.0.0.1/dogs.json")
.await
.expect_err("refused");
assert!(
matches!(err, IndexError::Fetch(FetchError::Url(_))),
"wrong variant: {err:?}"
);
}
#[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 bytes = fetch_index_document(&url).await.expect("read");
let index = parse_index(&bytes).expect("parses");
assert_eq!(index.dogs.len(), 1);
assert_eq!(index.sanitised, 1);
assert!(!index.dogs[0].description.contains('\u{1b}'));
}
}