memoryd: [honcho] and its keys checked at start, and brokerd's state socket
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -9,7 +9,7 @@ publish.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bxcore.workspace = true
|
||||
bxcore = { workspace = true, features = ["secrets"] }
|
||||
roncho.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11).
|
||||
//! `memoryd` configuration: read `memoryd.toml` into a typed `Config` (M5a spec, section 11), and
|
||||
//! check its `[honcho]` section at start (M5c spec, section 10).
|
||||
//!
|
||||
//! Unknown keys are errors in every table, as in the other roles: a misspelt key that silently
|
||||
//! fell back to its default would be a setting the owner believes is set and is not.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bxcore::DataClass;
|
||||
use bxcore::secrets::SecretSpec;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::honcho::config::{self as honcho, HonchoConfig, HonchoSection};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields, default)]
|
||||
pub struct Paths {
|
||||
@@ -65,6 +70,9 @@ pub struct Sockets {
|
||||
pub loopd: PathBuf,
|
||||
pub broker: PathBuf,
|
||||
pub admin: PathBuf,
|
||||
/// `brokerd`'s `state.sock`, which `memoryd` connects to, to ask for a session's state (M5c
|
||||
/// spec, section 7). `brokerd.toml` names the same path as its `[sockets] state`.
|
||||
pub state: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||
@@ -74,12 +82,18 @@ pub struct Config {
|
||||
pub memory: Memory,
|
||||
pub index: Index,
|
||||
pub sockets: Sockets,
|
||||
/// `[honcho]` as written; `honcho_config` checks it. Without it, Honcho is off.
|
||||
pub honcho: Option<HonchoSection>,
|
||||
/// `[secrets.<name>]`: the Honcho keys, checked by `honcho_config`, never read here.
|
||||
pub secrets: BTreeMap<String, SecretSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Read(PathBuf, std::io::Error),
|
||||
Parse(PathBuf, toml::de::Error),
|
||||
/// `[honcho]` or a key for it is not allowed: the path, and `honcho_config`'s reason.
|
||||
Honcho(PathBuf, String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
@@ -87,6 +101,7 @@ impl std::fmt::Display for ConfigError {
|
||||
match self {
|
||||
ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()),
|
||||
ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
|
||||
ConfigError::Honcho(path, why) => write!(f, "{}: {why}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,12 +113,15 @@ impl Config {
|
||||
toml::from_str(text)
|
||||
}
|
||||
|
||||
/// Read and parse `path`; a relative `soul_starter` is taken to be next to it.
|
||||
/// Read, parse and check `path`; a relative `soul_starter` is taken to be next to it.
|
||||
pub fn load(path: &Path) -> Result<Config, ConfigError> {
|
||||
let text =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
|
||||
let mut config =
|
||||
Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
|
||||
if let Err(why) = config.honcho_config() {
|
||||
return Err(ConfigError::Honcho(path.to_path_buf(), why));
|
||||
}
|
||||
if config.memory.soul_starter.is_relative() {
|
||||
let base = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
config.memory.soul_starter = base.join(&config.memory.soul_starter);
|
||||
@@ -111,6 +129,11 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// `[honcho]` and its keys, checked: `Ok(None)` when there is no `[honcho]` and no key.
|
||||
pub fn honcho_config(&self) -> Result<Option<HonchoConfig>, String> {
|
||||
honcho::check(self.honcho.as_ref(), &self.secrets)
|
||||
}
|
||||
|
||||
fn socket(&self, set: &Path, default: &str) -> PathBuf {
|
||||
if set.as_os_str().is_empty() {
|
||||
self.paths.home.join(default)
|
||||
@@ -128,6 +151,9 @@ impl Config {
|
||||
pub fn admin_socket(&self) -> PathBuf {
|
||||
self.socket(&self.sockets.admin, "run/owner-memory/admin.sock")
|
||||
}
|
||||
pub fn state_socket(&self) -> PathBuf {
|
||||
self.socket(&self.sockets.state, "run/memory-broker/state.sock")
|
||||
}
|
||||
pub fn memory_dir(&self) -> PathBuf {
|
||||
self.paths.home.join("memory")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! `[honcho]` in `memoryd.toml` and its keys in `[secrets.<name>]`, checked at start into a
|
||||
//! `HonchoConfig` (M5c spec, sections 3, 6 and 10). Nothing here reaches Honcho or reads a key: a
|
||||
//! key's source is checked here and read only when a client is made.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bxcore::DataClass;
|
||||
use bxcore::secrets::{SecretSource, SecretSpec};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The runbook entry for every reason `check` gives.
|
||||
pub const RUNBOOK: &str = "see docs/runbook.md#honcho-config-invalid";
|
||||
|
||||
/// The longest name Honcho accepts for a workspace, a peer or a session (M5c spec, section 6).
|
||||
pub const MAX_NAME: usize = 512;
|
||||
|
||||
/// The names of the keys in `[secrets.<name>]`, one per workspace. A read workspace's key is
|
||||
/// `READ_KEY` and its agent's name (`read_key`).
|
||||
pub const TRUSTED_KEY: &str = "honcho_trusted";
|
||||
pub const UNTRUSTED_KEY: &str = "honcho_untrusted";
|
||||
pub const FLEET_KEY: &str = "honcho_fleet";
|
||||
pub const READ_KEY: &str = "honcho_read_";
|
||||
|
||||
/// `[honcho]` as written. Our own format: unknown keys are errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HonchoSection {
|
||||
pub url: String,
|
||||
pub agent: String,
|
||||
pub owner: String,
|
||||
pub trusted: String,
|
||||
pub untrusted: String,
|
||||
pub fleet: String,
|
||||
#[serde(default)]
|
||||
pub read: Vec<ReadSection>,
|
||||
}
|
||||
|
||||
/// One `[[honcho.read]]` as written. `trust` and `class` stay text until `check`, so that a wrong
|
||||
/// value is one of its reasons.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadSection {
|
||||
pub agent: String,
|
||||
pub workspace: String,
|
||||
#[serde(default)]
|
||||
pub trust: Option<String>,
|
||||
#[serde(default)]
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
/// `[honcho]` and its keys, checked: every name is a Honcho name, and the names fit together.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HonchoConfig {
|
||||
/// `http://` or `https://`, a host and an optional port.
|
||||
pub url: String,
|
||||
/// This agent's peer.
|
||||
pub agent: String,
|
||||
/// The owner's peer, the same in every workspace of the fleet.
|
||||
pub owner: String,
|
||||
/// This agent's three workspaces.
|
||||
pub trusted: String,
|
||||
pub untrusted: String,
|
||||
pub fleet: String,
|
||||
/// Other agents' workspaces this agent reads, in the order written.
|
||||
pub read: Vec<ReadWorkspace>,
|
||||
/// Every `[secrets.<name>]`, by its name: a source that is checked, never read here.
|
||||
pub keys: BTreeMap<String, SecretSource>,
|
||||
}
|
||||
|
||||
/// One `[[honcho.read]]`, checked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadWorkspace {
|
||||
pub agent: String,
|
||||
pub workspace: String,
|
||||
pub trust: Trust,
|
||||
pub class: DataClass,
|
||||
}
|
||||
|
||||
/// How the owner labelled another agent's workspace. `Untrusted` unless written out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Trust {
|
||||
Trusted,
|
||||
Untrusted,
|
||||
}
|
||||
|
||||
/// The name of the key for the workspace of the read agent `agent`.
|
||||
pub fn read_key(agent: &str) -> String {
|
||||
format!("{READ_KEY}{agent}")
|
||||
}
|
||||
|
||||
/// True when `name` is a Honcho name: 1 to `MAX_NAME` characters, each an ASCII letter, a digit,
|
||||
/// `_` or `-`.
|
||||
pub fn is_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_NAME
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
/// Check `[honcho]` and the keys in `[secrets]`. No section and no key is `Ok(None)`: Honcho is
|
||||
/// off. `Err` is the first reason found, one line that names the table and never a key's value.
|
||||
pub fn check(
|
||||
section: Option<&HonchoSection>,
|
||||
secrets: &BTreeMap<String, SecretSpec>,
|
||||
) -> Result<Option<HonchoConfig>, String> {
|
||||
let Some(section) = section else {
|
||||
if let Some(name) = secrets.keys().next() {
|
||||
return Err(format!(
|
||||
"[secrets.{name}] is set, but there is no [honcho] section"
|
||||
));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
check_url(§ion.url)?;
|
||||
for (key, name) in [
|
||||
("agent", §ion.agent),
|
||||
("owner", §ion.owner),
|
||||
("trusted", §ion.trusted),
|
||||
("untrusted", §ion.untrusted),
|
||||
("fleet", §ion.fleet),
|
||||
] {
|
||||
check_name("[honcho]", key, name)?;
|
||||
}
|
||||
if section.agent == section.owner {
|
||||
return Err(format!(
|
||||
"[honcho] agent and owner are both {:?}: the agent's peer must not be the owner's",
|
||||
section.agent
|
||||
));
|
||||
}
|
||||
check_own_workspaces(section)?;
|
||||
let mut read: Vec<ReadWorkspace> = Vec::new();
|
||||
for entry in §ion.read {
|
||||
let checked = read_workspace(section, entry, &read)?;
|
||||
read.push(checked);
|
||||
}
|
||||
let keys = check_keys(secrets, &read)?;
|
||||
Ok(Some(HonchoConfig {
|
||||
url: section.url.clone(),
|
||||
agent: section.agent.clone(),
|
||||
owner: section.owner.clone(),
|
||||
trusted: section.trusted.clone(),
|
||||
untrusted: section.untrusted.clone(),
|
||||
fleet: section.fleet.clone(),
|
||||
read,
|
||||
keys,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `Err` with the reason when `name`, the value of `key` in `table`, is not a Honcho name.
|
||||
fn check_name(table: &str, key: &str, name: &str) -> Result<(), String> {
|
||||
if is_name(name) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"{table} {key} {name:?} is not a Honcho name: 1 to {MAX_NAME} of a-z, A-Z, 0-9, _ and -"
|
||||
))
|
||||
}
|
||||
|
||||
/// `http://` or `https://`, then a host, then optionally `:` and a port; nothing else.
|
||||
fn check_url(url: &str) -> Result<(), String> {
|
||||
let rest = if let Some(rest) = url.strip_prefix("https://") {
|
||||
rest
|
||||
} else if let Some(rest) = url.strip_prefix("http://") {
|
||||
rest
|
||||
} else {
|
||||
return Err(URL_REASON.to_string());
|
||||
};
|
||||
let (host, port) = match rest.split_once(':') {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
return if host_ok(rest) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(URL_REASON.to_string())
|
||||
};
|
||||
}
|
||||
};
|
||||
if !host_ok(host) {
|
||||
return Err(URL_REASON.to_string());
|
||||
}
|
||||
let port_ok = !port.is_empty()
|
||||
&& port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().ok().is_some_and(|n| n > 0);
|
||||
if port_ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(URL_REASON.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The host of a URL: not empty, every byte an ASCII letter, digit, `.` or `-`.
|
||||
fn host_ok(host: &str) -> bool {
|
||||
!host.is_empty()
|
||||
&& host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
|
||||
}
|
||||
|
||||
/// The reason for any `url` that `check_url` refuses. It never quotes the URL, which could hold a
|
||||
/// password (`http://user:password@host`).
|
||||
const URL_REASON: &str =
|
||||
"[honcho] url must be http:// or https://, a host and an optional port, and nothing else";
|
||||
|
||||
/// `Err` when two of this agent's three workspaces have the same name.
|
||||
fn check_own_workspaces(section: &HonchoSection) -> Result<(), String> {
|
||||
let pairs = [
|
||||
(§ion.trusted, §ion.untrusted),
|
||||
(§ion.trusted, §ion.fleet),
|
||||
(§ion.untrusted, §ion.fleet),
|
||||
];
|
||||
for (a, b) in pairs {
|
||||
if a == b {
|
||||
return Err(format!(
|
||||
"[honcho] workspace {a:?} is named twice: trusted, untrusted and fleet must be three different workspaces"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `[[honcho.read]]`, checked against `[honcho]` and the entries before it, `earlier`.
|
||||
fn read_workspace(
|
||||
section: &HonchoSection,
|
||||
entry: &ReadSection,
|
||||
earlier: &[ReadWorkspace],
|
||||
) -> Result<ReadWorkspace, String> {
|
||||
check_name("[[honcho.read]]", "agent", &entry.agent)?;
|
||||
check_name("[[honcho.read]]", "workspace", &entry.workspace)?;
|
||||
if entry.agent == section.agent {
|
||||
return Err(format!(
|
||||
"[[honcho.read]] agent {:?} is this agent ([honcho] agent)",
|
||||
entry.agent
|
||||
));
|
||||
}
|
||||
if earlier.iter().any(|r| r.agent == entry.agent) {
|
||||
return Err(format!(
|
||||
"[[honcho.read]] agent {:?} is listed twice",
|
||||
entry.agent
|
||||
));
|
||||
}
|
||||
if entry.workspace == section.trusted
|
||||
|| entry.workspace == section.untrusted
|
||||
|| entry.workspace == section.fleet
|
||||
{
|
||||
return Err(format!(
|
||||
"[[honcho.read]] workspace {:?} is one of this agent's own",
|
||||
entry.workspace
|
||||
));
|
||||
}
|
||||
Ok(ReadWorkspace {
|
||||
agent: entry.agent.clone(),
|
||||
workspace: entry.workspace.clone(),
|
||||
trust: trust_of(entry)?,
|
||||
class: class_of(entry)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// The entry's `trust`: `Untrusted` when it is not written.
|
||||
fn trust_of(entry: &ReadSection) -> Result<Trust, String> {
|
||||
match entry.trust.as_deref() {
|
||||
None | Some("untrusted") => Ok(Trust::Untrusted),
|
||||
Some("trusted") => Ok(Trust::Trusted),
|
||||
Some(other) => Err(format!(
|
||||
"[[honcho.read]] trust {other:?} of agent {:?} is not \"trusted\" or \"untrusted\"",
|
||||
entry.agent
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry's `class`: `Private` when it is not written.
|
||||
fn class_of(entry: &ReadSection) -> Result<DataClass, String> {
|
||||
match entry.class.as_deref() {
|
||||
None | Some("private") => Ok(DataClass::Private),
|
||||
Some("public") => Ok(DataClass::Public),
|
||||
Some("secret") => Ok(DataClass::Secret),
|
||||
Some(other) => Err(format!(
|
||||
"[[honcho.read]] class {other:?} of agent {:?} is not \"public\", \"private\" or \"secret\"",
|
||||
entry.agent
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `[secrets.<name>]`: a Honcho key's name, and a source that `SecretSpec::source` accepts.
|
||||
fn check_keys(
|
||||
secrets: &BTreeMap<String, SecretSpec>,
|
||||
read: &[ReadWorkspace],
|
||||
) -> Result<BTreeMap<String, SecretSource>, String> {
|
||||
let mut keys: BTreeMap<String, SecretSource> = BTreeMap::new();
|
||||
for (name, spec) in secrets {
|
||||
let is_read_key = matches!(name.strip_prefix(READ_KEY), Some(agent) if read.iter().any(|r| r.agent == agent));
|
||||
if !is_read_key && name != TRUSTED_KEY && name != UNTRUSTED_KEY && name != FLEET_KEY {
|
||||
if let Some(agent) = name.strip_prefix(READ_KEY) {
|
||||
return Err(format!(
|
||||
"[secrets.{name}] is the key of agent {agent:?}, which is not in [[honcho.read]]"
|
||||
));
|
||||
}
|
||||
return Err(format!(
|
||||
"[secrets.{name}] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_<agent>"
|
||||
));
|
||||
}
|
||||
let source = spec
|
||||
.source()
|
||||
.map_err(|why| format!("[secrets.{name}] {why}"))?;
|
||||
keys.insert(name.clone(), source);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
|
||||
|
||||
pub mod config;
|
||||
@@ -4,6 +4,7 @@ pub mod config;
|
||||
pub mod edit;
|
||||
pub mod entries;
|
||||
pub mod files;
|
||||
pub mod honcho;
|
||||
pub mod index;
|
||||
pub mod journal;
|
||||
pub mod notes;
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bxcore::instance;
|
||||
use memoryd::config::Config;
|
||||
use memoryd::config::{Config, ConfigError};
|
||||
use memoryd::honcho::config::RUNBOOK as HONCHO_INVALID;
|
||||
use memoryd::serve;
|
||||
|
||||
const USAGE: &str = "usage: memoryd serve --config <path>";
|
||||
@@ -21,7 +22,11 @@ fn main() -> ExitCode {
|
||||
let config = match Config::load(&path) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("memoryd: {e}\n{START_FAILED}");
|
||||
let pointer = match &e {
|
||||
ConfigError::Read(..) | ConfigError::Parse(..) => START_FAILED,
|
||||
ConfigError::Honcho(..) => HONCHO_INVALID,
|
||||
};
|
||||
eprintln!("memoryd: {e}\n{pointer}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `[honcho]` in `memoryd.toml`, its `[[honcho.read]]` list and its keys, checked at start (M5c
|
||||
//! spec, section 10), and `[sockets] state`, `brokerd`'s session-state socket: what a valid
|
||||
//! section gives, and how `load` refuses an invalid one. The reasons are in `honcho_reasons.rs`.
|
||||
//! Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use bxcore::DataClass;
|
||||
use bxcore::secrets::SecretSource;
|
||||
use memoryd::config::{Config, ConfigError};
|
||||
use memoryd::honcho::config::{
|
||||
FLEET_KEY, HonchoConfig, MAX_NAME, READ_KEY, RUNBOOK, ReadWorkspace, TRUSTED_KEY, Trust,
|
||||
UNTRUSTED_KEY, read_key,
|
||||
};
|
||||
use tmp::TempDir;
|
||||
|
||||
/// The smallest valid section: the six keys, no read workspace, no key.
|
||||
const SECTION: &str = "[honcho]
|
||||
url = \"http://rift.scylla-hammerhead.ts.net:8000\"
|
||||
agent = \"boxmaker0\"
|
||||
owner = \"kyle\"
|
||||
trusted = \"boxmaker0-trusted\"
|
||||
untrusted = \"boxmaker0-untrusted\"
|
||||
fleet = \"fleet\"
|
||||
";
|
||||
|
||||
/// Two read workspaces: the first with the defaults, the second with both written out.
|
||||
const READS: &str = "[[honcho.read]]
|
||||
agent = \"boxmaker1\"
|
||||
workspace = \"boxmaker1-trusted\"
|
||||
[[honcho.read]]
|
||||
agent = \"boxmaker2\"
|
||||
workspace = \"boxmaker2-trusted\"
|
||||
trust = \"trusted\"
|
||||
class = \"secret\"
|
||||
";
|
||||
|
||||
/// `SECTION` with `from` (found exactly once) replaced by `to`, then `extra`.
|
||||
fn with(from: &str, to: &str, extra: &str) -> String {
|
||||
assert_eq!(SECTION.matches(from).count(), 1, "{from}");
|
||||
format!("{}{extra}", SECTION.replace(from, to))
|
||||
}
|
||||
|
||||
/// The checked section of `text`, which must parse.
|
||||
fn checked(text: &str) -> Result<Option<HonchoConfig>, String> {
|
||||
let config = Config::parse(text).unwrap_or_else(|e| panic!("{text}\n{e}"));
|
||||
config.honcho_config()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_honcho_section_it_is_none() {
|
||||
for text in [
|
||||
"",
|
||||
"[paths]\nhome = \"/srv/bx\"\n",
|
||||
"[memory]\nreview = true\n[sockets]\nloop = \"/r/l.sock\"\n",
|
||||
] {
|
||||
assert_eq!(checked(text), Ok(None), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_smallest_section_is_checked_as_written() {
|
||||
let honcho = checked(SECTION).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
honcho,
|
||||
HonchoConfig {
|
||||
url: "http://rift.scylla-hammerhead.ts.net:8000".to_string(),
|
||||
agent: "boxmaker0".to_string(),
|
||||
owner: "kyle".to_string(),
|
||||
trusted: "boxmaker0-trusted".to_string(),
|
||||
untrusted: "boxmaker0-untrusted".to_string(),
|
||||
fleet: "fleet".to_string(),
|
||||
read: Vec::new(),
|
||||
keys: BTreeMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_workspaces_default_to_untrusted_and_private_and_keep_their_order() {
|
||||
let honcho = checked(&format!("{SECTION}{READS}")).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
honcho.read,
|
||||
vec![
|
||||
ReadWorkspace {
|
||||
agent: "boxmaker1".to_string(),
|
||||
workspace: "boxmaker1-trusted".to_string(),
|
||||
trust: Trust::Untrusted,
|
||||
class: DataClass::Private,
|
||||
},
|
||||
ReadWorkspace {
|
||||
agent: "boxmaker2".to_string(),
|
||||
workspace: "boxmaker2-trusted".to_string(),
|
||||
trust: Trust::Trusted,
|
||||
class: DataClass::Secret,
|
||||
},
|
||||
]
|
||||
);
|
||||
let written = "[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\ntrust = \"untrusted\"\nclass = \"public\"\n[[honcho.read]]\nagent = \"b2\"\nworkspace = \"w2\"\nclass = \"private\"\n";
|
||||
let honcho = checked(&format!("{SECTION}{written}")).unwrap().unwrap();
|
||||
let labels: Vec<(Trust, DataClass)> = honcho.read.iter().map(|r| (r.trust, r.class)).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
vec![
|
||||
(Trust::Untrusted, DataClass::Public),
|
||||
(Trust::Untrusted, DataClass::Private)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_is_checked_by_its_name_and_never_read() {
|
||||
// None of these exists: a key is checked here, and read only when a client is made.
|
||||
let keys = "[secrets.honcho_trusted]\nenv = \"BOXMAKER_TEST_NO_SUCH_VARIABLE\"\n\
|
||||
[secrets.honcho_untrusted]\nfile = \"/nonexistent/boxmaker/untrusted.key\"\n\
|
||||
[secrets.honcho_fleet]\ncredential = \"honcho-fleet\"\n\
|
||||
[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_READ_2\"\n";
|
||||
let honcho = checked(&format!("{SECTION}{READS}{keys}"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let expected: BTreeMap<String, SecretSource> = [
|
||||
(
|
||||
"honcho_trusted",
|
||||
SecretSource::Env("BOXMAKER_TEST_NO_SUCH_VARIABLE".to_string()),
|
||||
),
|
||||
(
|
||||
"honcho_untrusted",
|
||||
SecretSource::File(PathBuf::from("/nonexistent/boxmaker/untrusted.key")),
|
||||
),
|
||||
(
|
||||
"honcho_fleet",
|
||||
SecretSource::Credential("honcho-fleet".to_string()),
|
||||
),
|
||||
(
|
||||
"honcho_read_boxmaker2",
|
||||
SecretSource::Env("HONCHO_READ_2".to_string()),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, source)| (name.to_string(), source))
|
||||
.collect();
|
||||
assert_eq!(honcho.keys, expected);
|
||||
assert_eq!(honcho.read.len(), 2, "the keys change nothing else");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_names_and_the_pointer() {
|
||||
assert_eq!(TRUSTED_KEY, "honcho_trusted");
|
||||
assert_eq!(UNTRUSTED_KEY, "honcho_untrusted");
|
||||
assert_eq!(FLEET_KEY, "honcho_fleet");
|
||||
assert_eq!(READ_KEY, "honcho_read_");
|
||||
assert_eq!(read_key("boxmaker1"), "honcho_read_boxmaker1");
|
||||
assert_eq!(RUNBOOK, "see docs/runbook.md#honcho-config-invalid");
|
||||
assert_eq!(MAX_NAME, 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_and_wrong_types_are_parse_errors() {
|
||||
for text in [
|
||||
with("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"", ""),
|
||||
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\nlabel = \"x\"\n"),
|
||||
format!("{SECTION}[secrets.honcho_trusted]\nenv = \"K\"\nvalue = \"x\"\n"),
|
||||
format!("{SECTION}[honcho.read]\nagent = \"b1\"\nworkspace = \"w1\"\n"),
|
||||
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\nworkspace = \"w1\"\ntrust = true\n"),
|
||||
format!("{SECTION}[[honcho.read]]\nagent = \"b1\"\n"),
|
||||
format!("{SECTION}[sockets]\nstates = \"/x\"\n"),
|
||||
with("owner = \"kyle\"\n", "", ""),
|
||||
with("agent = \"boxmaker0\"", "agent = 0", ""),
|
||||
"[secrets]\nhoncho_trusted = \"HONCHO_KEY\"\n".to_string(),
|
||||
] {
|
||||
assert!(Config::parse(&text).is_err(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_socket_is_under_home_unless_set() {
|
||||
let config = Config::parse("[paths]\nhome = \"/srv/bx\"\n").unwrap();
|
||||
assert_eq!(
|
||||
config.state_socket(),
|
||||
PathBuf::from("/srv/bx/run/memory-broker/state.sock")
|
||||
);
|
||||
let config =
|
||||
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nstate = \"/r/s.sock\"\n").unwrap();
|
||||
assert_eq!(config.state_socket(), PathBuf::from("/r/s.sock"));
|
||||
let config = Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nstate = \"\"\n").unwrap();
|
||||
assert_eq!(
|
||||
config.state_socket(),
|
||||
PathBuf::from("/h/run/memory-broker/state.sock")
|
||||
);
|
||||
// The other three do not move.
|
||||
assert_eq!(
|
||||
config.loop_socket(),
|
||||
PathBuf::from("/h/run/loop-memory/memory.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_refuses_an_invalid_section_and_keeps_a_valid_one() {
|
||||
let dir = TempDir::new("honcho-load");
|
||||
let good = dir.write("good.toml", &format!("{SECTION}{READS}"));
|
||||
let config = Config::load(&good).unwrap();
|
||||
assert_eq!(config.honcho_config().unwrap().unwrap().read.len(), 2);
|
||||
|
||||
let bad = dir.write(
|
||||
"bad.toml",
|
||||
&with("owner = \"kyle\"", "owner = \"boxmaker0\"", ""),
|
||||
);
|
||||
let err = Config::load(&bad).unwrap_err();
|
||||
let why =
|
||||
"[honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's";
|
||||
assert!(
|
||||
matches!(&err, ConfigError::Honcho(path, reason) if path == &bad && reason == why),
|
||||
"{err:?}"
|
||||
);
|
||||
assert_eq!(err.to_string(), format!("{}: {why}", bad.display()));
|
||||
|
||||
let unknown = dir.write(
|
||||
"unknown.toml",
|
||||
&with("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"", ""),
|
||||
);
|
||||
assert!(
|
||||
matches!(Config::load(&unknown), Err(ConfigError::Parse(..))),
|
||||
"an unknown key stays a parse error"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Every reason an invalid `[honcho]` section, `[[honcho.read]]` or Honcho key is refused with at
|
||||
//! start (M5c spec, sections 6 and 10): each invalid section has one problem and one exact reason.
|
||||
//! Do not edit.
|
||||
|
||||
use memoryd::config::Config;
|
||||
use memoryd::honcho::config::{HonchoConfig, is_name};
|
||||
|
||||
/// The smallest valid section: the six keys, no read workspace, no key.
|
||||
const SECTION: &str = "[honcho]
|
||||
url = \"http://rift.scylla-hammerhead.ts.net:8000\"
|
||||
agent = \"boxmaker0\"
|
||||
owner = \"kyle\"
|
||||
trusted = \"boxmaker0-trusted\"
|
||||
untrusted = \"boxmaker0-untrusted\"
|
||||
fleet = \"fleet\"
|
||||
";
|
||||
|
||||
const URL_REASON: &str =
|
||||
"[honcho] url must be http:// or https://, a host and an optional port, and nothing else";
|
||||
|
||||
/// `SECTION` with `from` (found exactly once) replaced by `to`, then `extra`.
|
||||
fn with(from: &str, to: &str, extra: &str) -> String {
|
||||
assert_eq!(SECTION.matches(from).count(), 1, "{from}");
|
||||
format!("{}{extra}", SECTION.replace(from, to))
|
||||
}
|
||||
|
||||
/// The checked section of `text`, which must parse.
|
||||
fn checked(text: &str) -> Result<Option<HonchoConfig>, String> {
|
||||
let config = Config::parse(text).unwrap_or_else(|e| panic!("{text}\n{e}"));
|
||||
config.honcho_config()
|
||||
}
|
||||
|
||||
fn reason(text: &str) -> String {
|
||||
match checked(text) {
|
||||
Ok(ok) => panic!("accepted:\n{text}\n{ok:?}"),
|
||||
Err(why) => why,
|
||||
}
|
||||
}
|
||||
|
||||
fn name_reason(table: &str, key: &str, name: &str) -> String {
|
||||
format!("{table} {key} {name:?} is not a Honcho name: 1 to 512 of a-z, A-Z, 0-9, _ and -")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honcho_names_at_the_edges() {
|
||||
let longest = "a".repeat(512);
|
||||
for name in [
|
||||
"a",
|
||||
"Z",
|
||||
"0",
|
||||
"_",
|
||||
"-",
|
||||
"boxmaker0-trusted",
|
||||
"A_b-9",
|
||||
longest.as_str(),
|
||||
] {
|
||||
assert!(is_name(name), "{name}");
|
||||
}
|
||||
let too_long = "a".repeat(513);
|
||||
for name in [
|
||||
"",
|
||||
too_long.as_str(),
|
||||
"boxmaker.0",
|
||||
"box maker",
|
||||
"a/b",
|
||||
"a:b",
|
||||
"kyle@home",
|
||||
"caf\u{e9}",
|
||||
"tab\t",
|
||||
"new\nline",
|
||||
"\u{0660}",
|
||||
] {
|
||||
assert!(!is_name(name), "{name:?}");
|
||||
}
|
||||
// The longest name is accepted in every place a name goes.
|
||||
let text = with("fleet = \"fleet\"", &format!("fleet = \"{longest}\""), "");
|
||||
assert_eq!(checked(&text).unwrap().unwrap().fleet, longest);
|
||||
let text = with("owner = \"kyle\"", &format!("owner = \"{too_long}\""), "");
|
||||
assert_eq!(reason(&text), name_reason("[honcho]", "owner", &too_long));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_that_are_accepted() {
|
||||
for url in [
|
||||
"http://rift.scylla-hammerhead.ts.net:8000",
|
||||
"https://rift.scylla-hammerhead.ts.net",
|
||||
"http://127.0.0.1:1",
|
||||
"http://honcho:65535",
|
||||
"https://Rift-2.Example.org:443",
|
||||
"http://h",
|
||||
] {
|
||||
let text = with(
|
||||
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
|
||||
&format!("url = \"{url}\""),
|
||||
"",
|
||||
);
|
||||
assert_eq!(checked(&text).unwrap().unwrap().url, url);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_that_are_refused_and_the_reason_never_quotes_them() {
|
||||
for url in [
|
||||
"",
|
||||
"rift:8000",
|
||||
"ftp://rift:8000",
|
||||
"HTTP://rift:8000",
|
||||
"http:/rift",
|
||||
"http://",
|
||||
"https://",
|
||||
"http://:8000",
|
||||
"http://rift:",
|
||||
"http://rift:0",
|
||||
"http://rift:65536",
|
||||
"http://rift:+80",
|
||||
"http://rift:80:90",
|
||||
"http://rift:8000/",
|
||||
"http://rift:8000/v3",
|
||||
"http://rift/",
|
||||
"http://kyle:hunter2@rift:8000",
|
||||
"http://kyle@rift:8000",
|
||||
"http://kyle@rift",
|
||||
"http://rift?x=1",
|
||||
"http://rift#top",
|
||||
"http://ri ft",
|
||||
" http://rift",
|
||||
"http://rift_1",
|
||||
"http://[::1]:8000",
|
||||
] {
|
||||
let text = with(
|
||||
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
|
||||
&format!("url = \"{url}\""),
|
||||
"",
|
||||
);
|
||||
assert_eq!(reason(&text), URL_REASON, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_invalid_section_gives_its_reason() {
|
||||
let read = |agent: &str, workspace: &str| {
|
||||
format!("[[honcho.read]]\nagent = \"{agent}\"\nworkspace = \"{workspace}\"\n")
|
||||
};
|
||||
let cases: Vec<(String, String)> = vec![
|
||||
(
|
||||
with("agent = \"boxmaker0\"", "agent = \"boxmaker.0\"", ""),
|
||||
name_reason("[honcho]", "agent", "boxmaker.0"),
|
||||
),
|
||||
(
|
||||
with("owner = \"kyle\"", "owner = \"\"", ""),
|
||||
name_reason("[honcho]", "owner", ""),
|
||||
),
|
||||
(
|
||||
with(
|
||||
"trusted = \"boxmaker0-trusted\"",
|
||||
"trusted = \"boxmaker0 trusted\"",
|
||||
"",
|
||||
),
|
||||
name_reason("[honcho]", "trusted", "boxmaker0 trusted"),
|
||||
),
|
||||
(
|
||||
with(
|
||||
"untrusted = \"boxmaker0-untrusted\"",
|
||||
"untrusted = \"boxmaker0/untrusted\"",
|
||||
"",
|
||||
),
|
||||
name_reason("[honcho]", "untrusted", "boxmaker0/untrusted"),
|
||||
),
|
||||
(
|
||||
with("fleet = \"fleet\"", "fleet = \"fleet:all\"", ""),
|
||||
name_reason("[honcho]", "fleet", "fleet:all"),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker.1", "boxmaker1-trusted")),
|
||||
name_reason("[[honcho.read]]", "agent", "boxmaker.1"),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker1", "boxmaker1.trusted")),
|
||||
name_reason("[[honcho.read]]", "workspace", "boxmaker1.trusted"),
|
||||
),
|
||||
(
|
||||
with("owner = \"kyle\"", "owner = \"boxmaker0\"", ""),
|
||||
"[honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
with(
|
||||
"untrusted = \"boxmaker0-untrusted\"",
|
||||
"untrusted = \"boxmaker0-trusted\"",
|
||||
"",
|
||||
),
|
||||
"[honcho] workspace \"boxmaker0-trusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
with("fleet = \"fleet\"", "fleet = \"boxmaker0-trusted\"", ""),
|
||||
"[honcho] workspace \"boxmaker0-trusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
with("fleet = \"fleet\"", "fleet = \"boxmaker0-untrusted\"", ""),
|
||||
"[honcho] workspace \"boxmaker0-untrusted\" is named twice: trusted, untrusted and fleet must be three different workspaces"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker0", "boxmaker1-trusted")),
|
||||
"[[honcho.read]] agent \"boxmaker0\" is this agent ([honcho] agent)".to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}{}",
|
||||
read("boxmaker1", "boxmaker1-trusted"),
|
||||
read("boxmaker1", "boxmaker1-other")
|
||||
),
|
||||
"[[honcho.read]] agent \"boxmaker1\" is listed twice".to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker1", "boxmaker0-trusted")),
|
||||
"[[honcho.read]] workspace \"boxmaker0-trusted\" is one of this agent's own"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker1", "boxmaker0-untrusted")),
|
||||
"[[honcho.read]] workspace \"boxmaker0-untrusted\" is one of this agent's own"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}{}", read("boxmaker1", "fleet")),
|
||||
"[[honcho.read]] workspace \"fleet\" is one of this agent's own".to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}trust = \"Trusted\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[[honcho.read]] trust \"Trusted\" of agent \"boxmaker1\" is not \"trusted\" or \"untrusted\""
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}trust = \"\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[[honcho.read]] trust \"\" of agent \"boxmaker1\" is not \"trusted\" or \"untrusted\""
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}class = \"top-secret\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[[honcho.read]] class \"top-secret\" of agent \"boxmaker1\" is not \"public\", \"private\" or \"secret\""
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}class = \"Private\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[[honcho.read]] class \"Private\" of agent \"boxmaker1\" is not \"public\", \"private\" or \"secret\""
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}[secrets.honcho_trust]\nenv = \"HONCHO_KEY\"\n"),
|
||||
"[secrets.honcho_trust] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_<agent>"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}[secrets.mattermost_token]\nenv = \"MM_TOKEN\"\n"),
|
||||
"[secrets.mattermost_token] is not a Honcho key: memoryd has only honcho_trusted, honcho_untrusted, honcho_fleet and honcho_read_<agent>"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_KEY\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[secrets.honcho_read_boxmaker2] is the key of agent \"boxmaker2\", which is not in [[honcho.read]]"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}[secrets.honcho_read_boxmaker0]\nenv = \"HONCHO_KEY\"\n"),
|
||||
"[secrets.honcho_read_boxmaker0] is the key of agent \"boxmaker0\", which is not in [[honcho.read]]"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}[secrets.honcho_trusted]\n"),
|
||||
"[secrets.honcho_trusted] needs exactly one of credential, env and file".to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}[secrets.honcho_fleet]\nenv = \"HONCHO_KEY\"\nfile = \"/etc/honcho.key\"\n"
|
||||
),
|
||||
"[secrets.honcho_fleet] needs exactly one of credential, env and file".to_string(),
|
||||
),
|
||||
(
|
||||
format!("{SECTION}[secrets.honcho_untrusted]\nenv = \"honcho_key\"\n"),
|
||||
"[secrets.honcho_untrusted] env \"honcho_key\" is not a variable name (A-Z, 0-9, _)"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"{SECTION}{}[secrets.honcho_read_boxmaker1]\nfile = \"keys/boxmaker1\"\n",
|
||||
read("boxmaker1", "boxmaker1-trusted")
|
||||
),
|
||||
"[secrets.honcho_read_boxmaker1] file \"keys/boxmaker1\" must be an absolute path"
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"[secrets.honcho_trusted]\nenv = \"HONCHO_KEY\"\n".to_string(),
|
||||
"[secrets.honcho_trusted] is set, but there is no [honcho] section".to_string(),
|
||||
),
|
||||
];
|
||||
for (text, want) in &cases {
|
||||
assert_eq!(&reason(text), want, "\n{text}");
|
||||
}
|
||||
assert_eq!(cases.len(), 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_problem_is_the_one_reported() {
|
||||
// A bad url and an owner that is the agent: the url is checked first.
|
||||
let text = with(
|
||||
"url = \"http://rift.scylla-hammerhead.ts.net:8000\"",
|
||||
"url = \"rift\"",
|
||||
"",
|
||||
)
|
||||
.replace("owner = \"kyle\"", "owner = \"boxmaker0\"");
|
||||
assert_eq!(reason(&text), URL_REASON);
|
||||
// A read agent that is this agent, with a bad trust: the agent is checked first.
|
||||
let text = format!(
|
||||
"{SECTION}[[honcho.read]]\nagent = \"boxmaker0\"\nworkspace = \"w\"\ntrust = \"no\"\n"
|
||||
);
|
||||
assert_eq!(
|
||||
reason(&text),
|
||||
"[[honcho.read]] agent \"boxmaker0\" is this agent ([honcho] agent)"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `memoryd serve` with a `[honcho]` section (M5c spec, section 10): an invalid one stops it at
|
||||
//! start with the reason and the `honcho-config-invalid` pointer, before it takes its lock or
|
||||
//! touches its home; a valid one starts it as before. A key is never printed. Every wait has a
|
||||
//! deadline, and every process is killed and every path removed by a guard (tip T44). Do not
|
||||
//! edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bxcore::{Empty, Envelope, Message, PROTOCOL_VERSION, read_frame, write_frame};
|
||||
use tmp::TempDir;
|
||||
|
||||
const POINTER: &str = "see docs/runbook.md#honcho-config-invalid";
|
||||
const START_FAILED: &str = "see docs/runbook.md#memoryd-start-failed";
|
||||
|
||||
/// The values of the two keys the configs name. Built from pieces, so that no file holds one
|
||||
/// whole; neither may appear in anything `memoryd` prints.
|
||||
fn env_key() -> String {
|
||||
["hk", "env", "7f3a9c"].join("-")
|
||||
}
|
||||
fn file_key() -> String {
|
||||
["hk", "file", "b81d2e"].join("-")
|
||||
}
|
||||
|
||||
const SECTION: &str = "[honcho]
|
||||
url = \"http://127.0.0.1:9\"
|
||||
agent = \"boxmaker0\"
|
||||
owner = \"kyle\"
|
||||
trusted = \"boxmaker0-trusted\"
|
||||
untrusted = \"boxmaker0-untrusted\"
|
||||
fleet = \"fleet\"
|
||||
[[honcho.read]]
|
||||
agent = \"boxmaker1\"
|
||||
workspace = \"boxmaker1-trusted\"
|
||||
";
|
||||
|
||||
struct Home {
|
||||
dir: TempDir,
|
||||
config: PathBuf,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
/// A home whose `memoryd.toml` is the usual one and `extra`, with a key file holding
|
||||
/// `file_key()` (mode 0600) at `<home>/keys/honcho.key`.
|
||||
fn new(tag: &str, extra: &str) -> Home {
|
||||
let dir = TempDir::new(tag);
|
||||
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
|
||||
let key = dir.write("keys/honcho.key", &format!("{}\n", file_key()));
|
||||
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let text = format!(
|
||||
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{extra}",
|
||||
dir.path().display(),
|
||||
soul.display()
|
||||
);
|
||||
let text = text.replace("KEY_FILE", &key.display().to_string());
|
||||
let config = dir.write("memoryd.toml", &text);
|
||||
Home { dir, config }
|
||||
}
|
||||
|
||||
fn path(&self, relative: &str) -> PathBuf {
|
||||
self.dir.path().join(relative)
|
||||
}
|
||||
|
||||
fn command(&self) -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_memoryd"));
|
||||
command
|
||||
.args(["serve", "--config"])
|
||||
.arg(&self.config)
|
||||
.env("HONCHO_TEST_KEY", env_key())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
/// Runs `memoryd serve` to its end, at most 10 s (then it is killed), and gives its exit code
|
||||
/// (`None` if it had to be killed) and its stderr.
|
||||
fn run_to_end(&self) -> (Option<i32>, String) {
|
||||
let mut child = Daemon(self.command().stderr(Stdio::piped()).spawn().unwrap());
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut code = None;
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(Some(status)) = child.0.try_wait() {
|
||||
code = status.code();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let _ = child.0.kill();
|
||||
let _ = child.0.wait();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = child.0.stderr.take() {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
|
||||
}
|
||||
(code, stderr)
|
||||
}
|
||||
|
||||
/// `memoryd serve` with its stderr in `<home>/memoryd.err`.
|
||||
fn start(&self) -> Daemon {
|
||||
let err = std::fs::File::create(self.path("memoryd.err")).unwrap();
|
||||
Daemon(self.command().stderr(Stdio::from(err)).spawn().unwrap())
|
||||
}
|
||||
|
||||
fn stderr(&self) -> String {
|
||||
std::fs::read_to_string(self.path("memoryd.err")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A running `memoryd serve`, killed and waited for when dropped. Each test declares it after its
|
||||
/// `Home`, so it drops first.
|
||||
struct Daemon(Child);
|
||||
|
||||
impl Drop for Daemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// True once `memoryd` answers `memory_block` on `loopd`'s socket, within 10 s.
|
||||
fn answers(home: &Home) -> bool {
|
||||
let socket = home.path("run/loop-memory/memory.sock");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
if let Ok(mut stream) = UnixStream::connect(&socket) {
|
||||
let limit = Some(Duration::from_secs(5));
|
||||
let ask = Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg: Message::MemoryBlock(Empty {}),
|
||||
};
|
||||
if stream.set_read_timeout(limit).is_ok()
|
||||
&& write_frame(&mut stream, &ask).is_ok()
|
||||
&& matches!(
|
||||
read_frame(&mut stream),
|
||||
Ok(Envelope {
|
||||
msg: Message::MemoryBlockReply(_),
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn assert_no_key(text: &str) {
|
||||
assert!(
|
||||
!text.contains(&env_key()),
|
||||
"the env key was printed: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains(&file_key()),
|
||||
"the file key was printed: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_section_stops_memoryd_with_its_reason_and_the_pointer() {
|
||||
let extra = format!(
|
||||
"{}[secrets.honcho_trusted]\nenv = \"HONCHO_TEST_KEY\"\n[secrets.honcho_fleet]\nfile = \"KEY_FILE\"\n",
|
||||
SECTION.replace("owner = \"kyle\"", "owner = \"boxmaker0\"")
|
||||
);
|
||||
let home = Home::new("honcho-bad", &extra);
|
||||
let (code, stderr) = home.run_to_end();
|
||||
assert_eq!(code, Some(1), "{stderr}");
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!(
|
||||
"memoryd: {}: [honcho] agent and owner are both \"boxmaker0\": the agent's peer must not be the owner's\n{POINTER}\n",
|
||||
home.config.display()
|
||||
)
|
||||
);
|
||||
assert_no_key(&stderr);
|
||||
assert!(
|
||||
!home.path("run").exists(),
|
||||
"memoryd stopped before its lock and sockets"
|
||||
);
|
||||
assert!(!home.path("memory").exists(), "and before its memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_for_an_agent_not_read_stops_memoryd() {
|
||||
let extra = format!("{SECTION}[secrets.honcho_read_boxmaker2]\nenv = \"HONCHO_TEST_KEY\"\n");
|
||||
let home = Home::new("honcho-key", &extra);
|
||||
let (code, stderr) = home.run_to_end();
|
||||
assert_eq!(code, Some(1), "{stderr}");
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!(
|
||||
"memoryd: {}: [secrets.honcho_read_boxmaker2] is the key of agent \"boxmaker2\", which is not in [[honcho.read]]\n{POINTER}\n",
|
||||
home.config.display()
|
||||
)
|
||||
);
|
||||
assert_no_key(&stderr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_key_in_the_section_is_still_a_start_failure() {
|
||||
let extra = SECTION.replace("fleet = \"fleet\"", "fleet = \"fleet\"\nfleat = \"x\"");
|
||||
let home = Home::new("honcho-unknown", &extra);
|
||||
let (code, stderr) = home.run_to_end();
|
||||
assert_eq!(code, Some(1), "{stderr}");
|
||||
assert!(
|
||||
stderr.starts_with(&format!("memoryd: {}: ", home.config.display())),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(stderr.contains("fleat"), "{stderr}");
|
||||
assert!(stderr.ends_with(&format!("\n{START_FAILED}\n")), "{stderr}");
|
||||
assert!(!stderr.contains(POINTER), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_valid_section_with_keys_starts_memoryd_as_before() {
|
||||
let extra = format!(
|
||||
"{SECTION}[secrets.honcho_trusted]\nenv = \"HONCHO_TEST_KEY\"\n[secrets.honcho_fleet]\nfile = \"KEY_FILE\"\n[secrets.honcho_read_boxmaker1]\nenv = \"HONCHO_TEST_KEY\"\n"
|
||||
);
|
||||
let home = Home::new("honcho-good", &extra);
|
||||
let _daemon = home.start();
|
||||
assert!(
|
||||
answers(&home),
|
||||
"memoryd never answered; stderr: {}",
|
||||
home.stderr()
|
||||
);
|
||||
let stderr = home.stderr();
|
||||
assert!(stderr.contains("memoryd: serving\n"), "{stderr}");
|
||||
assert_no_key(&stderr);
|
||||
assert!(!stderr.contains(POINTER), "{stderr}");
|
||||
}
|
||||
Reference in New Issue
Block a user