mirror of
https://git.proxmox.com/git/proxmox
synced 2025-06-24 23:21:17 +00:00

For when the underlying datatype is supposed to contain the type property and the schema does not mark it as optional. The use case here is to support flat `Remote` type where the "type" of pve/pmg/pbs is a property which is present in the `Remote` struct while being derived from the section type. This will implicitly include and strip the type of the json object after/before de/serializing. Alternatives would be - to mark the type as optional and just fill it out later when loading the data, but that is technically wrong... - have a 2nd version of the struct with the type field removed and From/Into implemented, but that's even more unwieldy. Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
1296 lines
41 KiB
Rust
1296 lines
41 KiB
Rust
//! Configuration file helper class
|
|
//!
|
|
//! The `SectionConfig` class implements a configuration parser using
|
|
//! API Schemas. A Configuration may contain several sections, each
|
|
//! identified by an unique ID. Each section has a type, which is
|
|
//! associcatied with a Schema.
|
|
//!
|
|
//! The file syntax is configurable, but defaults to the following syntax:
|
|
//!
|
|
//! ```text
|
|
//! <section1_type>: <section1_id>
|
|
//! <key1> <value1>
|
|
//! <key2> <value2>
|
|
//! ...
|
|
//!
|
|
//! <section2_type>: <section2_id>
|
|
//! <key1> <value1>
|
|
//! ...
|
|
//! ```
|
|
|
|
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
|
|
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
use anyhow::{bail, format_err, Error};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::ser::Serialize;
|
|
use serde_json::{json, Value};
|
|
|
|
use proxmox_lang::try_block;
|
|
use proxmox_schema::format::{dump_properties, wrap_text, ParameterDisplayStyle};
|
|
use proxmox_schema::*;
|
|
|
|
pub mod typed;
|
|
|
|
/// Used for additional properties when the schema allows them.
|
|
const ADDITIONAL_PROPERTY_SCHEMA: Schema = StringSchema::new("Additional property").schema();
|
|
|
|
/// Associates a section type name with a `Schema`.
|
|
pub struct SectionConfigPlugin {
|
|
type_name: String,
|
|
properties: &'static (dyn ObjectSchemaType + Send + Sync + 'static),
|
|
id_property: Option<String>,
|
|
type_key: Option<&'static str>,
|
|
}
|
|
|
|
impl SectionConfigPlugin {
|
|
pub fn new(
|
|
type_name: String,
|
|
id_property: Option<String>,
|
|
properties: &'static (dyn ObjectSchemaType + Send + Sync + 'static),
|
|
) -> Self {
|
|
Self {
|
|
type_name,
|
|
properties,
|
|
id_property,
|
|
type_key: None,
|
|
}
|
|
}
|
|
|
|
/// Override the type key for this plugin.
|
|
pub const fn with_type_key(mut self, type_key: &'static str) -> Self {
|
|
self.type_key = Some(type_key);
|
|
self
|
|
}
|
|
|
|
pub fn type_name(&self) -> &str {
|
|
&self.type_name
|
|
}
|
|
|
|
pub fn id_property(&self) -> Option<&str> {
|
|
self.id_property.as_deref()
|
|
}
|
|
|
|
pub fn properties(&self) -> &(dyn ObjectSchemaType + Send + Sync + 'static) {
|
|
self.properties
|
|
}
|
|
|
|
pub fn get_id_schema(&self) -> Option<&Schema> {
|
|
match &self.id_property {
|
|
Some(id_prop) => {
|
|
if let Some((_, schema)) = self.properties.lookup(id_prop) {
|
|
Some(schema)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Configuration parser and writer using API Schemas
|
|
pub struct SectionConfig {
|
|
plugins: HashMap<String, SectionConfigPlugin>,
|
|
|
|
id_schema: &'static Schema,
|
|
parse_section_header: fn(&str) -> Option<(String, String)>,
|
|
parse_section_content: fn(&str) -> Option<(String, String)>,
|
|
format_section_header:
|
|
fn(type_name: &str, section_id: &str, data: &Value) -> Result<String, Error>,
|
|
format_section_content:
|
|
fn(type_name: &str, section_id: &str, key: &str, value: &Value) -> Result<String, Error>,
|
|
|
|
allow_unknown_sections: bool,
|
|
type_key: Option<&'static str>,
|
|
}
|
|
|
|
enum ParseState<'a> {
|
|
BeforeHeader,
|
|
InsideSection(&'a SectionConfigPlugin, String, Value),
|
|
InsideUnknownSection(String, String, Value),
|
|
}
|
|
|
|
/// Interface to manipulate configuration data
|
|
#[derive(Debug, Clone)]
|
|
pub struct SectionConfigData {
|
|
pub sections: HashMap<String, (String, Value)>,
|
|
pub order: Vec<String>,
|
|
}
|
|
|
|
impl Default for SectionConfigData {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl SectionConfigData {
|
|
/// Creates a new instance without any data.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
sections: HashMap::new(),
|
|
order: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Set data for the specified section.
|
|
///
|
|
/// Please not that this does not verify the data with the
|
|
/// schema. Instead, verification is done inside `write()`.
|
|
pub fn set_data<T: Serialize>(
|
|
&mut self,
|
|
section_id: &str,
|
|
type_name: &str,
|
|
config: T,
|
|
) -> Result<(), Error> {
|
|
let json = serde_json::to_value(config)?;
|
|
self.sections
|
|
.insert(section_id.to_string(), (type_name.to_string(), json));
|
|
Ok(())
|
|
}
|
|
|
|
/// Lookup section data as json `Value`.
|
|
pub fn lookup_json(&self, type_name: &str, id: &str) -> Result<Value, Error> {
|
|
match self.sections.get(id) {
|
|
Some((section_type_name, config)) => {
|
|
if type_name != section_type_name {
|
|
bail!(
|
|
"got unexpected type '{}' for {} '{}'",
|
|
section_type_name,
|
|
type_name,
|
|
id
|
|
);
|
|
}
|
|
Ok(config.clone())
|
|
}
|
|
None => {
|
|
bail!("no such {} '{}'", type_name, id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Lookup section data as native rust data type.
|
|
pub fn lookup<T: DeserializeOwned>(&self, type_name: &str, id: &str) -> Result<T, Error> {
|
|
let config = self.lookup_json(type_name, id)?;
|
|
let data = T::deserialize(config)?;
|
|
Ok(data)
|
|
}
|
|
|
|
/// Record section ordering
|
|
///
|
|
/// Sections are written in the recorder order.
|
|
pub fn record_order(&mut self, section_id: &str) {
|
|
self.order.push(section_id.to_string());
|
|
}
|
|
|
|
/// API helper to represent configuration data as array.
|
|
///
|
|
/// The array representation is useful to display configuration
|
|
/// data with GUI frameworks like ExtJS.
|
|
pub fn convert_to_array(
|
|
&self,
|
|
id_prop: &str,
|
|
digest: Option<&[u8; 32]>,
|
|
skip: &[&str],
|
|
) -> Value {
|
|
let mut list: Vec<Value> = vec![];
|
|
|
|
let digest: Value = match digest {
|
|
Some(v) => hex::encode(v).into(),
|
|
None => Value::Null,
|
|
};
|
|
|
|
for (section_id, (_, data)) in &self.sections {
|
|
let mut item = data.clone();
|
|
for prop in skip {
|
|
item.as_object_mut().unwrap().remove(*prop);
|
|
}
|
|
item.as_object_mut()
|
|
.unwrap()
|
|
.insert(id_prop.into(), section_id.clone().into());
|
|
if !digest.is_null() {
|
|
item.as_object_mut()
|
|
.unwrap()
|
|
.insert("digest".into(), digest.clone());
|
|
}
|
|
list.push(item);
|
|
}
|
|
|
|
list.into()
|
|
}
|
|
|
|
/// API helper to represent configuration data as typed array.
|
|
///
|
|
/// The array representation is useful to display configuration
|
|
/// data with GUI frameworks like ExtJS.
|
|
pub fn convert_to_typed_array<T: DeserializeOwned>(
|
|
&self,
|
|
type_name: &str,
|
|
) -> Result<Vec<T>, Error> {
|
|
let mut list: Vec<T> = vec![];
|
|
|
|
for (section_type, data) in self.sections.values() {
|
|
if section_type == type_name {
|
|
list.push(T::deserialize(data.clone())?);
|
|
}
|
|
}
|
|
|
|
Ok(list)
|
|
}
|
|
}
|
|
|
|
impl SectionConfig {
|
|
/// Create a new `SectionConfig` using the default syntax.
|
|
///
|
|
/// To make it usable, you need to register one or more plugins
|
|
/// with `register_plugin()`.
|
|
pub fn new(id_schema: &'static Schema) -> Self {
|
|
Self {
|
|
plugins: HashMap::new(),
|
|
id_schema,
|
|
parse_section_header: Self::default_parse_section_header,
|
|
parse_section_content: Self::default_parse_section_content,
|
|
format_section_header: Self::default_format_section_header,
|
|
format_section_content: Self::default_format_section_content,
|
|
allow_unknown_sections: false,
|
|
type_key: None,
|
|
}
|
|
}
|
|
|
|
/// Create a new `SectionConfig` using the systemd config file syntax.
|
|
pub fn with_systemd_syntax(id_schema: &'static Schema) -> Self {
|
|
Self {
|
|
plugins: HashMap::new(),
|
|
id_schema,
|
|
parse_section_header: Self::systemd_parse_section_header,
|
|
parse_section_content: Self::systemd_parse_section_content,
|
|
format_section_header: Self::systemd_format_section_header,
|
|
format_section_content: Self::systemd_format_section_content,
|
|
allow_unknown_sections: false,
|
|
type_key: None,
|
|
}
|
|
}
|
|
|
|
/// Create a new `SectionConfig` using a custom syntax.
|
|
pub fn with_custom_syntax(
|
|
id_schema: &'static Schema,
|
|
parse_section_header: fn(&str) -> Option<(String, String)>,
|
|
parse_section_content: fn(&str) -> Option<(String, String)>,
|
|
format_section_header: fn(
|
|
type_name: &str,
|
|
section_id: &str,
|
|
data: &Value,
|
|
) -> Result<String, Error>,
|
|
format_section_content: fn(
|
|
type_name: &str,
|
|
section_id: &str,
|
|
key: &str,
|
|
value: &Value,
|
|
) -> Result<String, Error>,
|
|
) -> Self {
|
|
Self {
|
|
plugins: HashMap::new(),
|
|
id_schema,
|
|
parse_section_header,
|
|
parse_section_content,
|
|
format_section_header,
|
|
format_section_content,
|
|
allow_unknown_sections: false,
|
|
type_key: None,
|
|
}
|
|
}
|
|
|
|
pub const fn allow_unknown_sections(mut self, allow_unknown_sections: bool) -> Self {
|
|
self.allow_unknown_sections = allow_unknown_sections;
|
|
self
|
|
}
|
|
|
|
/// The default type key for all and unknown section types.
|
|
pub const fn with_type_key(mut self, type_key: &'static str) -> Self {
|
|
self.type_key = Some(type_key);
|
|
self
|
|
}
|
|
|
|
/// Register a plugin, which defines the `Schema` for a section type.
|
|
pub fn register_plugin(&mut self, plugin: SectionConfigPlugin) {
|
|
self.plugins.insert(plugin.type_name.clone(), plugin);
|
|
}
|
|
|
|
/// Access plugin config
|
|
pub fn plugins(&self) -> &HashMap<String, SectionConfigPlugin> {
|
|
&self.plugins
|
|
}
|
|
|
|
/// Write the configuration data to a String.
|
|
///
|
|
/// This verifies the whole data using the schemas defined in the
|
|
/// plugins. Please note that `filename` is only used to improve
|
|
/// error messages.
|
|
pub fn write<P: AsRef<Path>>(
|
|
&self,
|
|
filename: P,
|
|
config: &SectionConfigData,
|
|
) -> Result<String, Error> {
|
|
self.write_do(config)
|
|
.map_err(|e: Error| format_err!("writing {:?} failed: {}", filename.as_ref(), e))
|
|
}
|
|
|
|
fn write_do(&self, config: &SectionConfigData) -> Result<String, Error> {
|
|
let mut list = Vec::new();
|
|
|
|
let mut done = HashSet::new();
|
|
|
|
for section_id in &config.order {
|
|
if !config.sections.contains_key(section_id) {
|
|
continue;
|
|
};
|
|
list.push(section_id);
|
|
done.insert(section_id);
|
|
}
|
|
|
|
for section_id in config.sections.keys() {
|
|
if done.contains(section_id) {
|
|
continue;
|
|
};
|
|
list.push(section_id);
|
|
}
|
|
|
|
let mut raw = String::new();
|
|
|
|
for section_id in list {
|
|
let (type_name, section_config) = config.sections.get(section_id).unwrap();
|
|
|
|
match self.plugins.get(type_name) {
|
|
Some(plugin) => {
|
|
let id_schema = plugin.get_id_schema().unwrap_or(self.id_schema);
|
|
if let Err(err) = id_schema.parse_simple_value(section_id) {
|
|
bail!("syntax error in section identifier: {}", err.to_string());
|
|
}
|
|
if section_id.chars().any(|c| c.is_control()) {
|
|
bail!("detected unexpected control character in section ID.");
|
|
}
|
|
if let Err(err) = plugin.properties.verify_json(section_config) {
|
|
bail!("verify section '{}' failed - {}", section_id, err);
|
|
}
|
|
|
|
if !raw.is_empty() {
|
|
raw += "\n"
|
|
}
|
|
|
|
raw += &(self.format_section_header)(type_name, section_id, section_config)?;
|
|
|
|
for (key, value) in section_config.as_object().unwrap() {
|
|
if plugin.id_property.as_deref() == Some(key)
|
|
|| plugin.type_key == Some(key)
|
|
|| (plugin.type_key.is_none() && self.type_key == Some(key))
|
|
{
|
|
continue; // id and type are part of the section header
|
|
}
|
|
raw += &(self.format_section_content)(type_name, section_id, key, value)?;
|
|
}
|
|
}
|
|
None if self.allow_unknown_sections => {
|
|
if section_id.chars().any(|c| c.is_control()) {
|
|
bail!("detected unexpected control character in section ID.");
|
|
}
|
|
|
|
if !raw.is_empty() {
|
|
raw += "\n"
|
|
}
|
|
|
|
raw += &(self.format_section_header)(type_name, section_id, section_config)?;
|
|
|
|
for (key, value) in section_config.as_object().unwrap() {
|
|
raw += &(self.format_section_content)(type_name, section_id, key, value)?;
|
|
}
|
|
}
|
|
None => {
|
|
bail!("unknown section type '{type_name}'");
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(raw)
|
|
}
|
|
|
|
/// Parse configuration data.
|
|
///
|
|
/// This verifies the whole data using the schemas defined in the
|
|
/// plugins. Please note that `filename` is only used to improve
|
|
/// error messages.
|
|
pub fn parse<P: AsRef<Path>>(
|
|
&self,
|
|
filename: P,
|
|
raw: &str,
|
|
) -> Result<SectionConfigData, Error> {
|
|
let mut state = ParseState::BeforeHeader;
|
|
|
|
let test_required_properties = |value: &Value,
|
|
schema: &(dyn ObjectSchemaType + Send + Sync),
|
|
id_property: &Option<String>|
|
|
-> Result<(), Error> {
|
|
for (name, optional, _prop_schema) in schema.properties() {
|
|
if let Some(id_property) = id_property {
|
|
if name == id_property {
|
|
// the id_property is the section header, skip for requirement check
|
|
continue;
|
|
}
|
|
}
|
|
if !*optional && value[name] == Value::Null {
|
|
return Err(format_err!(
|
|
"property '{}' is missing and it is not optional.",
|
|
name
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
};
|
|
|
|
let mut line_no = 0;
|
|
|
|
try_block!({
|
|
let mut result = SectionConfigData::new();
|
|
|
|
try_block!({
|
|
for line in raw.lines() {
|
|
line_no += 1;
|
|
|
|
match state {
|
|
ParseState::BeforeHeader => {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if let Some((section_type, section_id)) =
|
|
(self.parse_section_header)(line)
|
|
{
|
|
//println!("OKLINE: type: {} ID: {}", section_type, section_id);
|
|
|
|
if let Some(plugin) = self.plugins.get(§ion_type) {
|
|
let section_data =
|
|
if let Some(type_key) = plugin.type_key.or(self.type_key) {
|
|
json!({type_key: section_type})
|
|
} else {
|
|
json!({})
|
|
};
|
|
|
|
let id_schema =
|
|
plugin.get_id_schema().unwrap_or(self.id_schema);
|
|
if let Err(err) = id_schema.parse_simple_value(§ion_id) {
|
|
bail!(
|
|
"syntax error in section identifier: {}",
|
|
err.to_string()
|
|
);
|
|
}
|
|
state =
|
|
ParseState::InsideSection(plugin, section_id, section_data);
|
|
} else if self.allow_unknown_sections {
|
|
let section_data = if let Some(type_key) = self.type_key {
|
|
json!({type_key: section_type})
|
|
} else {
|
|
json!({})
|
|
};
|
|
state = ParseState::InsideUnknownSection(
|
|
section_type,
|
|
section_id,
|
|
section_data,
|
|
);
|
|
} else {
|
|
bail!("unknown section type '{}'", section_type);
|
|
}
|
|
} else {
|
|
bail!("syntax error (expected header)");
|
|
}
|
|
}
|
|
ParseState::InsideSection(plugin, ref mut section_id, ref mut config) => {
|
|
if line.trim().is_empty() {
|
|
// finish section
|
|
test_required_properties(
|
|
config,
|
|
plugin.properties,
|
|
&plugin.id_property,
|
|
)?;
|
|
if let Some(id_property) = &plugin.id_property {
|
|
config[id_property] = Value::from(section_id.clone());
|
|
}
|
|
result.set_data(section_id, &plugin.type_name, config.take())?;
|
|
result.record_order(section_id);
|
|
|
|
state = ParseState::BeforeHeader;
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = (self.parse_section_content)(line) {
|
|
//println!("CONTENT: key: {} value: {}", key, value);
|
|
|
|
let schema = plugin.properties.lookup(&key);
|
|
let (is_array, prop_schema) = match schema {
|
|
Some((_optional, Schema::Array(ArraySchema { items, .. }))) => {
|
|
(true, items)
|
|
}
|
|
Some((_optional, ref prop_schema)) => (false, prop_schema),
|
|
None => match plugin.properties.additional_properties() {
|
|
true => (false, &&ADDITIONAL_PROPERTY_SCHEMA),
|
|
false => bail!("unknown property '{}'", key),
|
|
},
|
|
};
|
|
|
|
let value = match prop_schema.parse_simple_value(&value) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
bail!("property '{}': {}", key, err.to_string());
|
|
}
|
|
};
|
|
|
|
#[allow(clippy::collapsible_if)] // clearer
|
|
if is_array {
|
|
if config[&key] == Value::Null {
|
|
config[key] = json!([value]);
|
|
} else {
|
|
config[key].as_array_mut().unwrap().push(value);
|
|
}
|
|
} else if config[&key] == Value::Null {
|
|
config[key] = value;
|
|
} else {
|
|
bail!("duplicate property '{}'", key);
|
|
}
|
|
} else {
|
|
bail!("syntax error (expected section properties)");
|
|
}
|
|
}
|
|
ParseState::InsideUnknownSection(
|
|
ref section_type,
|
|
ref mut section_id,
|
|
ref mut config,
|
|
) => {
|
|
if line.trim().is_empty() {
|
|
// finish section
|
|
result.set_data(section_id, section_type, config.take())?;
|
|
result.record_order(section_id);
|
|
|
|
state = ParseState::BeforeHeader;
|
|
continue;
|
|
}
|
|
if let Some((key, value)) = (self.parse_section_content)(line) {
|
|
match &mut config[&key] {
|
|
Value::Null => config[key] = json!(value),
|
|
// Assume it's an array schema in order to handle actual array
|
|
// schemas as good as we can.
|
|
Value::String(current) => config[key] = json!([current, value]),
|
|
Value::Array(array) => array.push(json!(value)),
|
|
other => bail!("got unexpected Value {:?}", other),
|
|
}
|
|
} else {
|
|
bail!("syntax error (expected section properties)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match state {
|
|
ParseState::BeforeHeader => {}
|
|
ParseState::InsideSection(plugin, ref mut section_id, ref mut config) => {
|
|
// finish section
|
|
test_required_properties(config, plugin.properties, &plugin.id_property)?;
|
|
if let Some(id_property) = &plugin.id_property {
|
|
config[id_property] = Value::from(section_id.clone());
|
|
}
|
|
result.set_data(section_id, &plugin.type_name, config)?;
|
|
result.record_order(section_id);
|
|
}
|
|
ParseState::InsideUnknownSection(
|
|
ref section_type,
|
|
ref mut section_id,
|
|
ref mut config,
|
|
) => {
|
|
// finish section
|
|
result.set_data(section_id, section_type, config)?;
|
|
result.record_order(section_id);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.map_err(|e| format_err!("line {} - {}", line_no, e))?;
|
|
|
|
Ok(result)
|
|
})
|
|
.map_err(|e: Error| format_err!("parsing {:?} failed: {}", filename.as_ref(), e))
|
|
}
|
|
|
|
fn default_format_section_header(
|
|
type_name: &str,
|
|
section_id: &str,
|
|
_data: &Value,
|
|
) -> Result<String, Error> {
|
|
Ok(format!("{}: {}\n", type_name, section_id))
|
|
}
|
|
|
|
fn default_format_section_content(
|
|
_type_name: &str,
|
|
section_id: &str,
|
|
key: &str,
|
|
value: &Value,
|
|
) -> Result<String, Error> {
|
|
if let Value::Array(array) = value {
|
|
let mut list = String::new();
|
|
for item in array {
|
|
let line = Self::default_format_section_content(_type_name, section_id, key, item)?;
|
|
if !line.is_empty() {
|
|
list.push_str(&line);
|
|
}
|
|
}
|
|
return Ok(list);
|
|
}
|
|
|
|
let text = match value {
|
|
Value::Null => return Ok(String::new()), // return empty string (delete)
|
|
Value::Bool(v) => v.to_string(),
|
|
Value::String(v) => v.to_string(),
|
|
Value::Number(v) => v.to_string(),
|
|
_ => {
|
|
bail!(
|
|
"got unsupported type in section '{}' key '{}'",
|
|
section_id,
|
|
key
|
|
);
|
|
}
|
|
};
|
|
|
|
if text.chars().any(|c| c.is_control()) {
|
|
bail!(
|
|
"detected unexpected control character in section '{}' key '{}'",
|
|
section_id,
|
|
key
|
|
);
|
|
}
|
|
|
|
Ok(format!("\t{} {}\n", key, text))
|
|
}
|
|
|
|
fn default_parse_section_content(line: &str) -> Option<(String, String)> {
|
|
if line.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let first_char = line.chars().next().unwrap();
|
|
|
|
if !first_char.is_whitespace() {
|
|
return None;
|
|
}
|
|
|
|
let mut kv_iter = line.trim_start().splitn(2, |c: char| c.is_whitespace());
|
|
|
|
let key = match kv_iter.next() {
|
|
Some(v) => v.trim(),
|
|
None => return None,
|
|
};
|
|
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let value = match kv_iter.next() {
|
|
Some(v) => v.trim(),
|
|
None => return None,
|
|
};
|
|
|
|
Some((key.into(), value.into()))
|
|
}
|
|
|
|
fn default_parse_section_header(line: &str) -> Option<(String, String)> {
|
|
if line.is_empty() {
|
|
return None;
|
|
};
|
|
|
|
let first_char = line.chars().next().unwrap();
|
|
|
|
if !first_char.is_alphabetic() {
|
|
return None;
|
|
}
|
|
|
|
let mut head_iter = line.splitn(2, ':');
|
|
|
|
let section_type = match head_iter.next() {
|
|
Some(v) => v.trim(),
|
|
None => return None,
|
|
};
|
|
|
|
if section_type.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let section_id = match head_iter.next() {
|
|
Some(v) => v.trim(),
|
|
None => return None,
|
|
};
|
|
|
|
Some((section_type.into(), section_id.into()))
|
|
}
|
|
|
|
fn systemd_format_section_header(
|
|
type_name: &str,
|
|
section_id: &str,
|
|
_data: &Value,
|
|
) -> Result<String, Error> {
|
|
if type_name != section_id {
|
|
bail!("gut unexpected section type");
|
|
}
|
|
|
|
Ok(format!("[{}]\n", section_id))
|
|
}
|
|
|
|
fn systemd_format_section_content(
|
|
_type_name: &str,
|
|
section_id: &str,
|
|
key: &str,
|
|
value: &Value,
|
|
) -> Result<String, Error> {
|
|
if let Value::Array(array) = value {
|
|
let mut list = String::new();
|
|
for item in array {
|
|
let line = Self::systemd_format_section_content(_type_name, section_id, key, item)?;
|
|
if !line.is_empty() {
|
|
list.push_str(&line);
|
|
}
|
|
}
|
|
return Ok(list);
|
|
}
|
|
|
|
let text = match value {
|
|
Value::Null => return Ok(String::new()), // return empty string (delete)
|
|
Value::Bool(v) => v.to_string(),
|
|
Value::String(v) => v.to_string(),
|
|
Value::Number(v) => v.to_string(),
|
|
_ => {
|
|
bail!(
|
|
"got unsupported type in section '{}' key '{}'",
|
|
section_id,
|
|
key
|
|
);
|
|
}
|
|
};
|
|
|
|
if text.chars().any(|c| c.is_control()) {
|
|
bail!(
|
|
"detected unexpected control character in section '{}' key '{}'",
|
|
section_id,
|
|
key
|
|
);
|
|
}
|
|
|
|
Ok(format!("{}={}\n", key, text))
|
|
}
|
|
|
|
fn systemd_parse_section_content(line: &str) -> Option<(String, String)> {
|
|
let line = line.trim_end();
|
|
|
|
if line.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if line.starts_with(char::is_whitespace) {
|
|
return None;
|
|
}
|
|
|
|
let kvpair: Vec<&str> = line.splitn(2, '=').collect();
|
|
|
|
if kvpair.len() != 2 {
|
|
return None;
|
|
}
|
|
|
|
let key = kvpair[0].trim_end();
|
|
let value = kvpair[1].trim_start();
|
|
|
|
Some((key.into(), value.into()))
|
|
}
|
|
|
|
fn systemd_parse_section_header(line: &str) -> Option<(String, String)> {
|
|
let line = line.trim_end();
|
|
|
|
if line.is_empty() {
|
|
return None;
|
|
};
|
|
|
|
if !line.starts_with('[') || !line.ends_with(']') {
|
|
return None;
|
|
}
|
|
|
|
let section = line[1..line.len() - 1].trim();
|
|
|
|
if section.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some((section.into(), section.into()))
|
|
}
|
|
}
|
|
|
|
// cargo test test_section_config1 -- --nocapture
|
|
#[test]
|
|
fn test_section_config1() {
|
|
let filename = "storage.cfg";
|
|
|
|
//let mut file = File::open(filename).expect("file not found");
|
|
//let mut contents = String::new();
|
|
//file.read_to_string(&mut contents).unwrap();
|
|
|
|
const PROPERTIES: ObjectSchema = ObjectSchema::new(
|
|
"lvmthin properties",
|
|
&[
|
|
(
|
|
"content",
|
|
true,
|
|
&StringSchema::new("Storage content types.").schema(),
|
|
),
|
|
(
|
|
"thinpool",
|
|
false,
|
|
&StringSchema::new("LVM thin pool name.").schema(),
|
|
),
|
|
(
|
|
"vgname",
|
|
false,
|
|
&StringSchema::new("LVM volume group name.").schema(),
|
|
),
|
|
],
|
|
);
|
|
|
|
let plugin = SectionConfigPlugin::new("lvmthin".to_string(), None, &PROPERTIES);
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("Storage ID schema.")
|
|
.min_length(3)
|
|
.schema();
|
|
let mut config = SectionConfig::new(&ID_SCHEMA);
|
|
config.register_plugin(plugin);
|
|
|
|
let raw = r"
|
|
|
|
lvmthin: local-lvm
|
|
thinpool data
|
|
vgname pve5
|
|
content rootdir,images
|
|
|
|
lvmthin: local-lvm2
|
|
thinpool data
|
|
vgname pve5
|
|
content rootdir,images
|
|
";
|
|
|
|
let res = config.parse(filename, raw);
|
|
println!("RES: {:?}", res);
|
|
let raw = config.write(filename, &res.unwrap());
|
|
println!("CONFIG:\n{}", raw.unwrap());
|
|
}
|
|
|
|
// cargo test test_section_config2 -- --nocapture
|
|
#[test]
|
|
fn test_section_config2() {
|
|
let filename = "user.cfg";
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("default id schema.")
|
|
.min_length(3)
|
|
.schema();
|
|
let mut config = SectionConfig::new(&ID_SCHEMA);
|
|
|
|
const USER_PROPERTIES: ObjectSchema = ObjectSchema::new(
|
|
"user properties",
|
|
&[
|
|
(
|
|
"email",
|
|
false,
|
|
&StringSchema::new("The e-mail of the user").schema(),
|
|
),
|
|
(
|
|
"userid",
|
|
true,
|
|
&StringSchema::new("The id of the user (name@realm).")
|
|
.min_length(3)
|
|
.schema(),
|
|
),
|
|
],
|
|
);
|
|
|
|
const GROUP_PROPERTIES: ObjectSchema = ObjectSchema::new(
|
|
"group properties",
|
|
&[
|
|
("comment", false, &StringSchema::new("Comment").schema()),
|
|
(
|
|
"groupid",
|
|
true,
|
|
&StringSchema::new("The id of the group.")
|
|
.min_length(3)
|
|
.schema(),
|
|
),
|
|
],
|
|
);
|
|
|
|
let plugin = SectionConfigPlugin::new(
|
|
"user".to_string(),
|
|
Some("userid".to_string()),
|
|
&USER_PROPERTIES,
|
|
);
|
|
config.register_plugin(plugin);
|
|
|
|
let plugin = SectionConfigPlugin::new(
|
|
"group".to_string(),
|
|
Some("groupid".to_string()),
|
|
&GROUP_PROPERTIES,
|
|
);
|
|
config.register_plugin(plugin);
|
|
|
|
let raw = r"
|
|
|
|
user: root@pam
|
|
email root@example.com
|
|
|
|
group: mygroup
|
|
comment a very important group
|
|
";
|
|
|
|
let res = config.parse(filename, raw);
|
|
println!("RES: {:?}", res);
|
|
let raw = config.write(filename, &res.unwrap());
|
|
println!("CONFIG:\n{}", raw.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_section_config_with_all_of_schema() {
|
|
let filename = "storage.cfg";
|
|
|
|
const PART1: Schema = ObjectSchema::new(
|
|
"properties 1",
|
|
&[(
|
|
"content",
|
|
true,
|
|
&StringSchema::new("Storage content types.").schema(),
|
|
)],
|
|
)
|
|
.schema();
|
|
|
|
const PART2: Schema = ObjectSchema::new(
|
|
"properties 2",
|
|
&[(
|
|
"thinpool",
|
|
false,
|
|
&StringSchema::new("LVM thin pool name.").schema(),
|
|
)],
|
|
)
|
|
.schema();
|
|
|
|
const PROPERTIES: AllOfSchema = AllOfSchema::new("properties", &[&PART1, &PART2]);
|
|
|
|
let plugin = SectionConfigPlugin::new("lvmthin".to_string(), None, &PROPERTIES);
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("Storage ID schema.")
|
|
.min_length(3)
|
|
.schema();
|
|
let mut config = SectionConfig::new(&ID_SCHEMA);
|
|
config.register_plugin(plugin);
|
|
|
|
let raw = r"lvmthin: local-lvm
|
|
content rootdir,images
|
|
thinpool data
|
|
|
|
lvmthin: local-lvm2
|
|
content rootdir,images
|
|
thinpool data
|
|
";
|
|
|
|
let res = config.parse(filename, raw);
|
|
println!("RES: {:?}", res);
|
|
let created = config
|
|
.write(filename, &res.unwrap())
|
|
.expect("failed to write config");
|
|
println!("CONFIG:\n{}", raw);
|
|
|
|
assert_eq!(raw, created);
|
|
}
|
|
|
|
#[test]
|
|
fn test_section_config_with_additional_properties() {
|
|
let filename = "user.cfg";
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("default id schema.")
|
|
.min_length(3)
|
|
.schema();
|
|
let mut config = SectionConfig::new(&ID_SCHEMA);
|
|
let mut config_with_additional = SectionConfig::new(&ID_SCHEMA);
|
|
|
|
const PROPERTIES: [(&str, bool, &proxmox_schema::Schema); 2] = [
|
|
(
|
|
"email",
|
|
false,
|
|
&StringSchema::new("The e-mail of the user").schema(),
|
|
),
|
|
(
|
|
"userid",
|
|
true,
|
|
&StringSchema::new("The id of the user (name@realm).")
|
|
.min_length(3)
|
|
.schema(),
|
|
),
|
|
];
|
|
|
|
const USER_PROPERTIES: ObjectSchema = ObjectSchema {
|
|
description: "user properties",
|
|
properties: &PROPERTIES,
|
|
additional_properties: false,
|
|
default_key: None,
|
|
};
|
|
|
|
const USER_PROPERTIES_WITH_ADDITIONAL: ObjectSchema = ObjectSchema {
|
|
description: "user properties with additional",
|
|
properties: &PROPERTIES,
|
|
additional_properties: true,
|
|
default_key: None,
|
|
};
|
|
|
|
let plugin = SectionConfigPlugin::new(
|
|
"user".to_string(),
|
|
Some("userid".to_string()),
|
|
&USER_PROPERTIES,
|
|
);
|
|
config.register_plugin(plugin);
|
|
|
|
let plugin = SectionConfigPlugin::new(
|
|
"user".to_string(),
|
|
Some("userid".to_string()),
|
|
&USER_PROPERTIES_WITH_ADDITIONAL,
|
|
);
|
|
config_with_additional.register_plugin(plugin);
|
|
|
|
let raw = r"
|
|
|
|
user: root@pam
|
|
email root@example.com
|
|
shinynewoption somevalue
|
|
";
|
|
|
|
let res = config_with_additional.parse(filename, raw);
|
|
println!("RES: {:?}", res);
|
|
let written = config_with_additional.write(filename, &res.unwrap());
|
|
println!("CONFIG:\n{}", written.unwrap());
|
|
|
|
assert!(config.parse(filename, raw).is_err());
|
|
// SectionConfigData doesn't have Clone and it would only be needed here currently.
|
|
let res = config_with_additional.parse(filename, raw);
|
|
assert!(config.write(filename, &res.unwrap()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_section_config_with_unknown_section_types() {
|
|
let filename = "user.cfg";
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("default id schema.")
|
|
.min_length(3)
|
|
.schema();
|
|
let mut config = SectionConfig::new(&ID_SCHEMA).allow_unknown_sections(true);
|
|
|
|
const PROPERTIES: [(&str, bool, &proxmox_schema::Schema); 2] = [
|
|
(
|
|
"email",
|
|
false,
|
|
&StringSchema::new("The e-mail of the user").schema(),
|
|
),
|
|
(
|
|
"userid",
|
|
true,
|
|
&StringSchema::new("The id of the user (name@realm).")
|
|
.min_length(3)
|
|
.schema(),
|
|
),
|
|
];
|
|
|
|
const USER_PROPERTIES: ObjectSchema = ObjectSchema {
|
|
description: "user properties",
|
|
properties: &PROPERTIES,
|
|
additional_properties: false,
|
|
default_key: None,
|
|
};
|
|
|
|
let plugin = SectionConfigPlugin::new(
|
|
"user".to_string(),
|
|
Some("userid".to_string()),
|
|
&USER_PROPERTIES,
|
|
);
|
|
config.register_plugin(plugin);
|
|
|
|
let raw = r"
|
|
|
|
user: root@pam
|
|
email root@example.com
|
|
|
|
token: asdf@pbs!asdftoken
|
|
enable true
|
|
expire 0
|
|
";
|
|
|
|
let check = |res: SectionConfigData| {
|
|
let (_, token_config) = res.sections.get("root@pam").unwrap();
|
|
assert_eq!(
|
|
*token_config.get("email").unwrap(),
|
|
json!("root@example.com")
|
|
);
|
|
|
|
let (token_id, token_config) = res.sections.get("asdf@pbs!asdftoken").unwrap();
|
|
assert_eq!(token_id, "token");
|
|
assert_eq!(*token_config.get("enable").unwrap(), json!("true"));
|
|
assert_eq!(*token_config.get("expire").unwrap(), json!("0"));
|
|
};
|
|
|
|
let res = config.parse(filename, raw).unwrap();
|
|
println!("RES: {:?}", res);
|
|
let written = config.write(filename, &res);
|
|
println!("CONFIG:\n{}", written.as_ref().unwrap());
|
|
|
|
check(res);
|
|
|
|
let res = config.parse(filename, &written.unwrap()).unwrap();
|
|
println!("RES second time: {:?}", res);
|
|
|
|
check(res);
|
|
|
|
let config = config.allow_unknown_sections(false);
|
|
|
|
assert!(config.parse(filename, raw).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_section_config_array() {
|
|
let filename = "sync.cfg";
|
|
|
|
const PROPERTIES: ObjectSchema = ObjectSchema::new(
|
|
"Dummy sync job properties",
|
|
&[
|
|
(
|
|
"group-filter",
|
|
true,
|
|
&ArraySchema::new(
|
|
"Group filter array schema",
|
|
&StringSchema::new("Group filter entry schema.").schema(),
|
|
)
|
|
.schema(),
|
|
),
|
|
(
|
|
"schedule",
|
|
true,
|
|
&StringSchema::new("Remote schema.").schema(),
|
|
),
|
|
],
|
|
);
|
|
|
|
let plugin = SectionConfigPlugin::new("sync".to_string(), None, &PROPERTIES);
|
|
|
|
const ID_SCHEMA: Schema = StringSchema::new("ID schema.").min_length(3).schema();
|
|
|
|
let mut config = SectionConfig::new(&ID_SCHEMA);
|
|
config.register_plugin(plugin);
|
|
|
|
let config_unknown = SectionConfig::new(&ID_SCHEMA).allow_unknown_sections(true);
|
|
|
|
let raw = r"
|
|
|
|
sync: s-4a1011e8-40e2
|
|
group-filter group:vm/144
|
|
schedule monthly
|
|
|
|
sync: s-5b2122f9-51f3
|
|
group-filter group:vm/100
|
|
schedule hourly
|
|
group-filter group:vm/102
|
|
|
|
sync: s-6c32330a-6204
|
|
group-filter group:vm/103
|
|
group-filter group:vm/104
|
|
group-filter group:vm/105
|
|
";
|
|
|
|
let check = |res: SectionConfigData| {
|
|
let (_, second_section) = res.sections.get("s-5b2122f9-51f3").unwrap();
|
|
assert_eq!(*second_section.get("schedule").unwrap(), json!("hourly"));
|
|
assert_eq!(
|
|
*second_section.get("group-filter").unwrap(),
|
|
json!(["group:vm/100", "group:vm/102"]),
|
|
);
|
|
|
|
let (_, third_section) = res.sections.get("s-6c32330a-6204").unwrap();
|
|
assert_eq!(
|
|
*third_section.get("group-filter").unwrap(),
|
|
json!(["group:vm/103", "group:vm/104", "group:vm/105"]),
|
|
);
|
|
assert!(third_section.get("schedule").is_none());
|
|
};
|
|
|
|
let res = config.parse(filename, raw).unwrap();
|
|
println!("RES: {:?}", res);
|
|
let written = config.write(filename, &res).unwrap();
|
|
println!("CONFIG:\n{}", written);
|
|
|
|
check(res);
|
|
|
|
let res = config.parse(filename, &written).unwrap();
|
|
println!("RES (second time): {:?}", res);
|
|
|
|
check(res);
|
|
|
|
let res_unknown = config_unknown.parse(filename, raw).unwrap();
|
|
println!("RES (unknown): {:?}", res_unknown);
|
|
let written_unknown = config_unknown.write(filename, &res_unknown).unwrap();
|
|
println!("CONFIG (unknown):\n{}", written_unknown);
|
|
|
|
check(res_unknown);
|
|
|
|
assert_eq!(written, written_unknown);
|
|
|
|
let raw = r"
|
|
|
|
sync: fail
|
|
schedule hourly
|
|
schedule monthly
|
|
";
|
|
|
|
assert!(config.parse(filename, raw).is_err());
|
|
}
|
|
|
|
/// Generate ReST Documentation for ``SectionConfig``
|
|
pub fn dump_section_config(config: &SectionConfig) -> String {
|
|
let mut res = String::new();
|
|
|
|
let mut plugins: Vec<&String> = config.plugins().keys().collect();
|
|
plugins.sort_unstable();
|
|
|
|
let plugin_count = config.plugins().len();
|
|
|
|
for name in plugins {
|
|
let plugin = config.plugins().get(name).unwrap();
|
|
let properties = plugin.properties();
|
|
let skip = match plugin.id_property() {
|
|
Some(id) => vec![id],
|
|
None => Vec::new(),
|
|
};
|
|
|
|
if plugin_count > 1 {
|
|
use std::fmt::Write as _;
|
|
|
|
let description = wrap_text("", "", properties.description(), 80);
|
|
let _ = write!(
|
|
res,
|
|
"\n**Section type** \'``{}``\': {}\n\n",
|
|
name, description
|
|
);
|
|
}
|
|
|
|
res.push_str(&dump_properties(
|
|
properties,
|
|
"",
|
|
ParameterDisplayStyle::Config,
|
|
&skip,
|
|
));
|
|
}
|
|
|
|
res
|
|
}
|