use std::io::Read;
use std::path::{Path, PathBuf};
use haematite::tree::Hash;
use serde_json::{Value, json};
pub const SCHEMA: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/access/schema.json"));
const APP: &str = "haematite";
const CREDENTIAL_BYTES: u64 = 4096;
const USAGE: &str = "usage: haematite lys-schema --issuer <origin> --credential-file <absolute path> \
(--check | --register | --record <file>)\n haematite lys-schema --recorded <file>";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
Check,
Register,
Record(PathBuf),
}
pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
let mut args = args.peekable();
if args.peek().map(String::as_str) == Some("--recorded") {
args.next();
let path = args.next().ok_or(USAGE)?;
if args.next().is_some() {
return Err(USAGE.to_owned());
}
return check_recorded(Path::new(&path));
}
let (issuer, credential, mode) = parse(args)?;
let schema: Value = serde_json::from_str(SCHEMA)
.map_err(|error| format!("schema.json is not JSON: {error}"))?;
let bearer = read_credential(&credential)?;
let address = url::Url::parse(&issuer)
.and_then(|origin| origin.join(&format!("api/apps/{APP}/schema")))
.map_err(|_| format!("the issuer {issuer} is not an address"))?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("the schema check's runtime could not start: {error}"))?;
runtime.block_on(async {
let bounds = crate::door::config::SignInBounds::from_env()?;
let http = schema_client(std::time::Duration::from_millis(bounds.total_ms.get()))?;
let (version, registered) = registered(&http, &address, &bearer).await?;
if let Mode::Record(path) = &mode {
let recorded = json!({ "version": version, "schema": registered });
let text = serde_json::to_string_pretty(&recorded)
.map_err(|error| format!("the recorded schema could not be written: {error}"))?;
std::fs::write(path, format!("{text}\n")).map_err(|error| {
format!(
"the recorded schema {} could not be written: {error}",
path.display()
)
})?;
eprintln!(
"recorded: {APP} schema version {version} in {}",
path.display()
);
return Ok(());
}
if canonical(®istered) == canonical(&schema) {
eprintln!("registered: {APP} schema version {version} is schema.json");
return Ok(());
}
if mode == Mode::Check {
return Err(format!(
"schema_drift: {APP} schema version {version} at {issuer} differs from \
docs/design/access/schema.json; register it with --register"
));
}
let body = json!({
"operation": operation_id(),
"replaces": version,
"schema": schema,
});
let answer = send(http.put(address.clone()).bearer_auth(&bearer), &body).await?;
let applied = answer["applied"].as_bool().unwrap_or(false);
if applied {
eprintln!("registered: {APP} schema.json is version {}", version + 1);
} else {
eprintln!(
"proposed: {APP} schema.json waits for an administrator on Lys's Apps screen, \
replacing version {version}"
);
}
Ok(())
})
}
fn check_recorded(path: &Path) -> Result<(), String> {
let schema: Value = serde_json::from_str(SCHEMA)
.map_err(|error| format!("schema.json is not JSON: {error}"))?;
let text = std::fs::read_to_string(path).map_err(|error| {
format!(
"the recorded schema {} could not be read: {error}",
path.display()
)
})?;
let recorded: Value = serde_json::from_str(&text).map_err(|error| {
format!(
"the recorded schema {} is not JSON: {error}",
path.display()
)
})?;
let version = recorded["version"]
.as_u64()
.ok_or_else(|| format!("the recorded schema {} names no version", path.display()))?;
if canonical(&recorded["schema"]) == canonical(&schema) {
eprintln!("recorded: {APP} schema version {version} is schema.json");
return Ok(());
}
Err(format!(
"schema_drift: the recorded {APP} schema version {version} ({}) differs from \
docs/design/access/schema.json; register it, then record it with --record",
path.display()
))
}
fn parse(mut args: impl Iterator<Item = String>) -> Result<(String, PathBuf, Mode), String> {
let (mut issuer, mut credential, mut mode) = (None, None, None);
while let Some(arg) = args.next() {
match arg.as_str() {
"--issuer" if issuer.is_none() => issuer = Some(args.next().ok_or(USAGE)?),
"--credential-file" if credential.is_none() => {
credential = Some(PathBuf::from(args.next().ok_or(USAGE)?));
}
"--check" if mode.is_none() => mode = Some(Mode::Check),
"--register" if mode.is_none() => mode = Some(Mode::Register),
"--record" if mode.is_none() => {
mode = Some(Mode::Record(PathBuf::from(args.next().ok_or(USAGE)?)));
}
_ => return Err(format!("unknown or repeated argument {arg:?}\n{USAGE}")),
}
}
let credential = credential.ok_or(USAGE)?;
if !credential.is_absolute() {
return Err(USAGE.to_owned());
}
Ok((issuer.ok_or(USAGE)?, credential, mode.ok_or(USAGE)?))
}
pub(in crate::serve) fn read_credential(path: &Path) -> Result<String, String> {
let unreadable = || format!("the credential file {} could not be read", path.display());
let file = std::fs::File::open(path).map_err(|_| unreadable())?;
let mut text = String::new();
file.take(CREDENTIAL_BYTES + 1)
.read_to_string(&mut text)
.map_err(|_| unreadable())?;
let line = text.strip_suffix('\n').unwrap_or(&text);
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CREDENTIAL_BYTES
|| !line.starts_with("lys-app.haematite.")
|| line.chars().any(char::is_control)
{
return Err(format!(
"the credential file {} is not one line of haematite's Lys app credential",
path.display()
));
}
Ok(line.to_owned())
}
async fn registered(
http: &reqwest::Client,
address: &url::Url,
bearer: &str,
) -> Result<(u64, Value), String> {
let answer = send(http.get(address.clone()).bearer_auth(bearer), &Value::Null).await?;
let version = answer["version"]
.as_u64()
.ok_or("lys_could_not_be_asked: the schema answer names no version")?;
Ok((version, answer["schema"].clone()))
}
fn canonical(value: &Value) -> Value {
match value {
Value::Object(members) => Value::Object(
members
.iter()
.map(|(name, member)| (name.clone(), canonical(member)))
.filter(|(_, member)| !is_empty(member))
.collect(),
),
Value::Array(items) => {
let mut items: Vec<Value> = items.iter().map(canonical).collect();
if items.iter().all(Value::is_string) {
items.sort_by(|left, right| left.as_str().cmp(&right.as_str()));
}
Value::Array(items)
}
other => other.clone(),
}
}
fn is_empty(value: &Value) -> bool {
match value {
Value::Object(members) => members.is_empty(),
Value::Array(items) => items.is_empty(),
_ => false,
}
}
async fn send(request: reqwest::RequestBuilder, body: &Value) -> Result<Value, String> {
let request = if body.is_null() {
request
} else {
request
.header("content-type", "application/json")
.body(body.to_string())
};
let response = request
.send()
.await
.map_err(|_| "lys_could_not_be_asked: Lys could not be reached".to_owned())?;
let status = response.status();
let text = response
.text()
.await
.map_err(|_| "lys_could_not_be_asked: Lys's answer could not be read".to_owned())?;
let answer: Value = serde_json::from_str(&text)
.map_err(|_| format!("lys_could_not_be_asked: Lys answered {status} without JSON"))?;
if !status.is_success() {
let name = answer["refusal"]
.as_str()
.or_else(|| answer["error"].as_str())
.unwrap_or("lys_refused");
return Err(format!("{name}: Lys refused the schema request ({status})"));
}
Ok(answer)
}
fn operation_id() -> String {
let digest = Hash::of(SCHEMA.as_bytes()).to_string();
format!("op-{}", digest.get(..32).unwrap_or(&digest))
}
#[cfg(test)]
#[path = "lys_schema_tests.rs"]
mod tests;
fn schema_client(timeout: std::time::Duration) -> Result<reqwest::Client, String> {
let bounds = crate::door::config::SignInBounds::from_env()?;
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(bounds.connect_ms.get()).min(timeout))
.timeout(timeout)
.build()
.map_err(|_| "lys_could_not_be_asked: the schema HTTP client could not start".to_owned())
}
#[cfg(test)]
#[path = "lys_schema_deadline_tests.rs"]
mod deadline_tests;