use std::path::Path;
use std::time::Duration;
use super::Seed;
pub const METADATA_AT: &str = "169.254.169.254:80";
pub const METADATA_USER_DATA: &str = "/metadata/v1/user_data";
pub const METADATA_TRIES: u32 = 36;
pub const METADATA_EVERY: Duration = Duration::from_secs(5);
pub fn of_user_data(body: &str) -> Result<Seed, String> {
if body.trim().is_empty() {
return Err("the server's user_data is empty: it was laid without a seed".into());
}
Seed::parse(body).map_err(|e| format!("the server's user_data is not a pairing seed: {e}"))
}
pub fn staged_text(path: &Path, fetch: &dyn Fn() -> Result<String, String>, check: &dyn Fn(&str) -> Result<(), String>, tries: u32, every: Duration) -> Result<Option<String>, String> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
if !super::is_metadata_marker(&text) {
return Ok(Some(text));
}
let mut last = String::new();
for n in 0..tries.max(1) {
match fetch().and_then(|body| {
if body.trim().is_empty() {
return Err("the server's user_data is empty: it was laid without a seed".to_string());
}
check(&body).map_err(|e| format!("the server's user_data is not a pairing seed: {e}"))?;
Ok(body)
}) {
Ok(body) => {
appliance_pid1::durable::write_atomic(path, body.as_bytes(), Some(0o600)).map_err(|e| format!("{}: {e}", path.display()))?;
return Ok(Some(body));
}
Err(why) => last = why,
}
if n + 1 < tries {
std::thread::sleep(every);
}
}
Err(format!("REFUSED [pairing-seed-metadata] {}: a release medium's seed is the metadata's, and the metadata gave none after {tries} tries: {last}", path.display()))
}
pub fn staged(path: &Path, fetch: &dyn Fn() -> Result<String, String>, tries: u32, every: Duration) -> Result<Option<Seed>, String> {
let check = |t: &str| Seed::parse(t).map(|_| ()).map_err(|e| e.to_string());
let Some(text) = staged_text(path, fetch, &check, tries, every)? else { return Ok(None) };
Seed::parse(&text).map(Some).map_err(|e| format!("REFUSED [pairing-seed] {}: {e}", path.display()))
}
pub fn read(path: &Path) -> Result<Option<Seed>, String> {
staged(path, &user_data, METADATA_TRIES, METADATA_EVERY)
}
pub fn user_data() -> Result<String, String> {
let at: std::net::SocketAddr = METADATA_AT.parse().map_err(|e| format!("{METADATA_AT}: {e}"))?;
match http_get(at, "169.254.169.254", METADATA_USER_DATA)? {
(200, body) => Ok(body),
(st, body) => Err(format!("the metadata answered HTTP {st}: {}", body.chars().take(200).collect::<String>())),
}
}
pub fn http_get(at: std::net::SocketAddr, host: &str, path: &str) -> Result<(u16, String), String> {
use std::io::{Read as _, Write as _};
let mut tcp = std::net::TcpStream::connect_timeout(&at, Duration::from_secs(3)).map_err(|e| format!("{at}: {e}"))?;
tcp.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| e.to_string())?;
tcp.write_all(format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nAccept: text/plain\r\nConnection: close\r\n\r\n").as_bytes()).map_err(|e| format!("{at}: {e}"))?;
let mut raw = Vec::new();
tcp.read_to_end(&mut raw).map_err(|e| format!("{at}: {e}"))?;
let text = String::from_utf8_lossy(&raw);
let (head, body) = text.split_once("\r\n\r\n").ok_or_else(|| format!("{at} answered no HTTP"))?;
let status = head.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| format!("{at} answered no status: {}", head.lines().next().unwrap_or("")))?;
Ok((status, body.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::seed::{Kind, SEED_FROM_METADATA};
use std::cell::Cell;
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("nordisk-pairing-metadata-seed-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(_: &str) -> Result<(), String> {
Ok(())
}
#[test]
fn no_staged_file_is_no_seed_and_a_real_seed_is_read_as_it_is() {
let d = dir("plain");
let p = d.join("seed.toml");
let never = || -> Result<String, String> { panic!("a staged seed never asks the metadata") };
assert_eq!(staged_text(&p, &never, &ok, 3, Duration::ZERO).unwrap(), None);
std::fs::write(&p, "estate = \"acme\"\n").unwrap();
assert_eq!(staged_text(&p, &never, &ok, 3, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
}
#[test]
fn a_release_medium_takes_its_seed_from_the_metadata_and_keeps_it() {
let d = dir("meta");
let p = d.join("seed.toml");
std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
let asked = Cell::new(0);
let fetch = || {
asked.set(asked.get() + 1);
if asked.get() < 3 { Err("not yet".to_string()) } else { Ok("estate = \"acme\"\n".to_string()) }
};
assert_eq!(staged_text(&p, &fetch, &ok, 5, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
assert_eq!(asked.get(), 3, "asked until the metadata answered");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "estate = \"acme\"\n", "the seed is kept over the marker");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
}
let never = || -> Result<String, String> { panic!("a kept seed never asks again") };
assert_eq!(staged_text(&p, &never, &ok, 1, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
}
#[test]
fn an_empty_or_bad_user_data_is_refused_by_name_and_the_marker_stays() {
let d = dir("bad");
let p = d.join("seed.toml");
std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
let empty = || Ok(String::new());
let e = staged_text(&p, &empty, &ok, 2, Duration::ZERO).unwrap_err();
assert!(e.contains("[pairing-seed-metadata]") && e.contains("empty"), "{e}");
let junk = || Ok("not a seed".to_string());
let no = |_: &str| Err("bad toml".to_string());
let e = staged_text(&p, &junk, &no, 2, Duration::ZERO).unwrap_err();
assert!(e.contains("not a pairing seed") && e.contains("bad toml"), "{e}");
assert!(crate::seed::is_metadata_marker(&std::fs::read_to_string(&p).unwrap()), "nothing is written over the marker");
}
#[test]
fn the_typed_reader_parses_the_metadata_seed_and_refuses_a_staged_seed_that_lies() {
let d = dir("typed");
let p = d.join("seed.toml");
let never = || -> Result<String, String> { panic!("never asked") };
assert_eq!(staged(&p, &never, 1, Duration::ZERO).unwrap(), None, "none staged: none");
let (seed, _) = crate::seed::tests::a_seed(Kind::Holger);
let text = seed.render().unwrap();
std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
let junk_then_seed = Cell::new(0);
let fetch = || {
junk_then_seed.set(junk_then_seed.get() + 1);
Ok(if junk_then_seed.get() == 1 { "estate = \"acme\"\n".to_string() } else { text.clone() })
};
assert_eq!(staged(&p, &fetch, 3, Duration::ZERO).unwrap(), Some(seed.clone()), "a user_data that is no seed is asked again");
assert_eq!(junk_then_seed.get(), 2);
assert_eq!(Seed::parse(&std::fs::read_to_string(&p).unwrap()).unwrap(), seed, "kept over the marker");
std::fs::write(&p, "estate = \"acme\"\n").unwrap();
let e = staged(&p, &never, 1, Duration::ZERO).unwrap_err();
assert!(e.starts_with("REFUSED [pairing-seed]"), "{e}");
assert!(of_user_data(" \n").unwrap_err().contains("empty"));
assert_eq!(of_user_data(&text).unwrap(), seed);
}
#[test]
fn the_metadata_get_reads_a_200s_body_and_names_any_other_status() {
use std::io::{Read as _, Write as _};
let serve = |answer: &'static str| {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let at = l.local_addr().unwrap();
let t = std::thread::spawn(move || {
let (mut s, _) = l.accept().unwrap();
let mut req = [0u8; 512];
let n = s.read(&mut req).unwrap();
s.write_all(answer.as_bytes()).unwrap();
String::from_utf8_lossy(&req[..n]).to_string()
});
(at, t)
};
let (at, t) = serve("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nestate = \"acme\"\n");
assert_eq!(http_get(at, "169.254.169.254", METADATA_USER_DATA).unwrap(), (200, "estate = \"acme\"\n".to_string()));
let req = t.join().unwrap();
assert!(req.starts_with("GET /metadata/v1/user_data HTTP/1.0\r\n") && req.contains("Host: 169.254.169.254\r\n"), "{req}");
let (at, t) = serve("HTTP/1.0 404 Not Found\r\n\r\nno user_data");
assert_eq!(http_get(at, "h", "/x").unwrap().0, 404);
t.join().unwrap();
let (at, t) = serve("garbage");
assert!(http_get(at, "h", "/x").unwrap_err().contains("answered no HTTP"));
t.join().unwrap();
assert_eq!((METADATA_AT, METADATA_TRIES, METADATA_EVERY), ("169.254.169.254:80", 36, Duration::from_secs(5)), "three minutes, as both products asked");
}
}