blob: b7ff55fb26cca43e4bea4ef0037116706286391a [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use {
anyhow::anyhow,
fuchsia_syslog::{fx_log_err, fx_log_info},
fuchsia_url::pkg_url::PkgUrl,
fuchsia_zircon::Duration,
serde::Deserialize,
std::{cmp, fs::File, io::Read, num::NonZeroU64},
thiserror::Error,
};
/// Static service configuration options.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Config {
poll_frequency: Option<Duration>,
update_package_url: Option<PkgUrl>,
}
impl Config {
pub fn poll_frequency(&self) -> Option<Duration> {
self.poll_frequency
}
pub fn update_package_url(&self) -> Option<&PkgUrl> {
self.update_package_url.as_ref()
}
pub fn load_from_config_data_or_default() -> Config {
let f = match File::open("/config/data/ota_config.json") {
Ok(f) => f,
Err(e) => {
fx_log_info!("no config found, using defaults: {:#}", anyhow!(e));
return Config::default();
}
};
Self::load(f).unwrap_or_else(|e| {
fx_log_err!("unable to load config, using defaults: {:#}", anyhow!(e));
Config::default()
})
}
fn load(r: impl Read) -> Result<Config, ConfigLoadError> {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ParseConfig {
poll_frequency_minutes: Option<NonZeroU64>,
update_package_url: Option<PkgUrl>,
}
let config = serde_json::from_reader::<_, ParseConfig>(r)?;
if config.update_package_url.as_ref().map(|url| url.resource().is_some()).unwrap_or(false) {
return Err(ConfigLoadError::UpdatePackageUrlContainsResource);
}
Ok(Config {
poll_frequency: config.poll_frequency_minutes.map(|freq| {
// zx::Duration will wrap on overflow when converting to nanoseconds. Ensure a
// config file cannot specify a negative duration by clamping to the maximum number
// of minutes that can be represented as nanoseconds in an i64.
let max_minutes_duration =
Duration::from_nanos(std::i64::MAX).into_minutes() as u64;
Duration::from_minutes(cmp::min(freq.get(), max_minutes_duration) as i64)
}),
update_package_url: config.update_package_url,
})
}
}
#[derive(Debug, Error)]
enum ConfigLoadError {
#[error("parse error")]
Parse(#[from] serde_json::Error),
#[error("update_package_url must not contain a resource path")]
UpdatePackageUrlContainsResource,
}
#[cfg(test)]
#[derive(Debug)]
pub struct ConfigBuilder(Config);
#[cfg(test)]
impl ConfigBuilder {
pub fn new() -> Self {
Self(Config::default())
}
pub fn poll_frequency(mut self, duration: impl Into<Duration>) -> Self {
self.0.poll_frequency = Some(duration.into());
self
}
pub fn build(self) -> Config {
self.0
}
}
#[cfg(test)]
mod tests {
use {super::*, matches::assert_matches, serde_json::json};
fn verify_load(input: serde_json::Value, expected: Config) {
let input = input.to_string();
assert_eq!(Config::load(input.as_bytes()).unwrap(), expected);
}
#[test]
fn test_load() {
verify_load(
json!({
"update_package_url": "fuchsia-pkg://fuchsia.com/abc",
"poll_frequency_minutes": 123,
}),
Config {
poll_frequency: Some(Duration::from_minutes(123)),
update_package_url: Some(PkgUrl::parse("fuchsia-pkg://fuchsia.com/abc").unwrap()),
},
);
}
#[test]
fn test_missing_fields_are_defaults() {
verify_load(
json!({
"update_package_url": "fuchsia-pkg://fuchsia.com/the-update",
}),
Config {
poll_frequency: None,
update_package_url: Some(
PkgUrl::parse("fuchsia-pkg://fuchsia.com/the-update").unwrap(),
),
},
);
verify_load(
json!({
"poll_frequency_minutes": 1,
}),
Config { poll_frequency: Some(Duration::from_minutes(1)), update_package_url: None },
);
}
#[test]
fn test_no_config_data_is_default() {
assert_eq!(Config::load_from_config_data_or_default(), Config::default());
}
#[test]
fn test_load_empty_is_default() {
assert_matches!(
Config::load("{}".as_bytes()),
Ok(ref config) if config == &Config::default());
}
#[test]
fn test_load_rejects_invalid() {
assert_matches!(
Config::load("not json".as_bytes()),
Err(ConfigLoadError::Parse(ref err)) if err.is_syntax());
}
#[test]
fn test_load_rejects_resource_path() {
let input = json!({
"update_package_url": "fuchsia-pkg://fuchsia.com/update/0#unexpected/resource.path",
})
.to_string();
assert_matches!(
Config::load(input.as_bytes()),
Err(ConfigLoadError::UpdatePackageUrlContainsResource)
);
}
#[test]
fn test_load_rejects_zero_poll_frequency() {
let input = json!({
"poll_frequency_minutes": 0,
})
.to_string();
assert_matches!(
Config::load(input.as_bytes()),
Err(ConfigLoadError::Parse(ref err)) if err.is_data());
}
#[test]
fn test_load_clamps_large_poll_frequency() {
let max_duration = Duration::from_nanos(std::i64::MAX);
let max_duration_minutes = max_duration.into_minutes();
let verify_clamp = |minutes| {
verify_load(
json!({
"poll_frequency_minutes": minutes,
}),
Config {
poll_frequency: Some(Duration::from_minutes(max_duration_minutes)),
update_package_url: None,
},
);
};
verify_clamp(max_duration_minutes as u64);
verify_clamp(max_duration_minutes as u64 + 1);
verify_clamp(std::i64::MAX as u64);
verify_clamp(std::u64::MAX);
}
}