cat_dev/mion/cgis/
setup.rs

1//! APIs for parsing the data from `/setup.cgi`, an HTTP interface usually
2//! only meant for humans.
3
4use crate::{
5	errors::CatBridgeError,
6	mion::{
7		cgis::do_simple_request,
8		proto::cgis::{MionCGIErrors, SetupParameters},
9	},
10};
11use mac_address::MacAddress;
12use reqwest::{Body, Client, Method};
13use std::net::Ipv4Addr;
14
15/// Get the setup parameters from a particular MION IP.
16///
17/// ## Errors
18///
19/// If we cannot make a network request to the `setup.cgi` page, or get some
20/// sort of error condition back.
21pub async fn get_setup_parameters(mion_ip: Ipv4Addr) -> Result<SetupParameters, CatBridgeError> {
22	get_setup_parameters_with_raw_client(&Client::default(), mion_ip).await
23}
24
25/// Get the setup parameters from a particular MION IP, but with an existing
26/// HTTP client.
27///
28/// ## Errors
29///
30/// If we cannot make a network request to the `setup.cgi` page, or get some
31/// sort of error condition back.
32pub async fn get_setup_parameters_with_raw_client(
33	client: &Client,
34	mion_ip: Ipv4Addr,
35) -> Result<SetupParameters, CatBridgeError> {
36	let body_as_string = do_simple_request::<Body>(
37		client,
38		Method::GET,
39		format!("http://{mion_ip}/setup.cgi"),
40		None,
41		None,
42	)
43	.await?;
44
45	parse_response_body_from_setup(&body_as_string)
46}
47
48/// Parse the actual setup parameters from an HTML body.
49///
50/// This is seperated into it's own method for testability.
51///
52/// ## Errors
53///
54/// - If we can't parse the response as the HTML format MIONs should respond
55///   with.
56/// - If the type of inputs were incorrect.
57fn parse_response_body_from_setup(body_as_string: &str) -> Result<SetupParameters, CatBridgeError> {
58	Ok(SetupParameters::new(
59		find_input_from_body(&InputType::Text, "id_5", body_as_string)?
60			.parse::<Ipv4Addr>()
61			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
62		find_input_from_body(&InputType::Text, "id_6", body_as_string)?
63			.parse::<Ipv4Addr>()
64			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
65		find_input_from_body(&InputType::Text, "id_7", body_as_string)?
66			.parse::<Ipv4Addr>()
67			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
68		find_input_from_body(&InputType::BooleanRadio, "id_4", body_as_string)?
69			.parse::<bool>()
70			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
71		find_input_from_body(&InputType::BooleanRadio, "id_8", body_as_string)?
72			.parse::<bool>()
73			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
74		find_input_from_body(&InputType::Text, "id_9", body_as_string)?
75			.parse::<Ipv4Addr>()
76			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
77		find_input_from_body(&InputType::Text, "id_10", body_as_string)?
78			.parse::<Ipv4Addr>()
79			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
80		find_input_from_body(&InputType::BooleanRadio, "id_11", body_as_string)?
81			.parse::<bool>()
82			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
83		find_input_from_body(&InputType::Text, "id_12", body_as_string)?
84			.parse::<Ipv4Addr>()
85			.map_err(MionCGIErrors::HtmlResponseIpExpectedButNotThere)?,
86		find_input_from_body(&InputType::SelectedResultValue, "id_26", body_as_string)?
87			.parse::<u32>()
88			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?
89			.try_into()?,
90		find_input_from_body(&InputType::Text, "id_27", body_as_string)?
91			.parse::<u8>()
92			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
93		find_input_from_body(&InputType::Text, "id_13", body_as_string)?
94			.parse::<u16>()
95			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
96		find_input_from_body(&InputType::Text, "id_14", body_as_string)?
97			.parse::<u16>()
98			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
99		find_input_from_body(&InputType::Text, "id_15", body_as_string)?
100			.parse::<u16>()
101			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
102		find_input_from_body(&InputType::Text, "id_16", body_as_string)?
103			.parse::<u16>()
104			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
105		find_input_from_body(&InputType::Text, "id_17", body_as_string)?
106			.parse::<u16>()
107			.map_err(MionCGIErrors::HtmlResponseNumberExpectedButNotThere)?,
108		find_input_from_body(&InputType::BooleanRadio, "id_33", body_as_string)?
109			.parse::<bool>()
110			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
111		find_input_from_body(&InputType::BooleanRadio, "id_32", body_as_string)?
112			.parse::<bool>()
113			.expect("impossible, this function only returns Ok(true) or Ok(false)"),
114		find_input_from_body(&InputType::Text, "id_28", body_as_string)?,
115		find_input_from_body(&InputType::Text, "id_29", body_as_string)?,
116		find_input_from_body(&InputType::Text, "id_30", body_as_string)?,
117		find_input_from_body(&InputType::Text, "id_31", body_as_string)?,
118		find_input_from_body(&InputType::TextArea, "id_2", body_as_string)?,
119		find_input_from_body(
120			&InputType::TableItemWithPrefix("MAC Address"),
121			"",
122			body_as_string,
123		)?
124		.parse::<MacAddress>()
125		.map_err(MionCGIErrors::HtmlResponseMacExpectedButNotThere)?,
126	))
127}
128
129enum InputType<'inner> {
130	Text,
131	TextArea,
132	BooleanRadio,
133	SelectedResultValue,
134	TableItemWithPrefix(&'inner str),
135}
136
137fn find_input_from_body(
138	input_typ: &InputType<'_>,
139	input_id: &str,
140	html_response: &str,
141) -> Result<String, MionCGIErrors> {
142	match input_typ {
143		InputType::Text => {
144			let string_to_find = format!("<input type=\"text\" name=\"{input_id}\"");
145			let pos = get_html_value(input_id, html_response, html_response, &string_to_find)?;
146
147			let starts_at_input = &html_response[pos..];
148			let value_start = get_html_value(input_id, html_response, starts_at_input, "value=\"")?;
149			let next_value = &starts_at_input[value_start + 7..];
150			let ending_value = get_html_value(input_id, html_response, next_value, "\">")?;
151			let final_value = &next_value[..ending_value];
152
153			Ok(final_value.to_owned())
154		}
155		InputType::TextArea => {
156			let pos = get_html_value(
157				input_id,
158				html_response,
159				html_response,
160				&format!("<textarea name=\"{input_id}\""),
161			)?;
162			let starts_at_input = &html_response[pos..];
163			let end_tag_pos = get_html_value(input_id, html_response, starts_at_input, ">")?;
164			let minus_start_tag = &starts_at_input[end_tag_pos + 1..];
165			let closing_tag =
166				get_html_value(input_id, html_response, minus_start_tag, "</textarea>")?;
167			let value = &minus_start_tag[..closing_tag];
168
169			Ok(value.to_owned())
170		}
171		InputType::BooleanRadio => {
172			if html_response.contains(&format!(
173				"<input type=\"radio\" name=\"{input_id}\" value=\"1\" checked>"
174			)) {
175				Ok("true".to_owned())
176			} else if html_response.contains(&format!(
177				"<input type=\"radio\" name=\"{input_id}\" value=\"0\" checked>"
178			)) {
179				Ok("false".to_owned())
180			} else {
181				Err(MionCGIErrors::HtmlResponseNoRadioChecked(
182					html_response.to_owned(),
183				))
184			}
185		}
186		InputType::SelectedResultValue => {
187			let start_input_pos = get_html_value(
188				input_id,
189				html_response,
190				html_response,
191				&format!("<select name=\"{input_id}\">"),
192			)?;
193			let start_at_select_value = &html_response[start_input_pos + 15 + input_id.len()..];
194			let select_end_pos =
195				get_html_value(input_id, html_response, start_at_select_value, "</select>")?;
196			let selects = &start_at_select_value[..select_end_pos];
197			let end_input_pos = get_html_value(input_id, html_response, selects, "\" selected>")?;
198			let ends_at_selected_value = &selects[..end_input_pos];
199			let start_option_location = rget_html_value(
200				input_id,
201				html_response,
202				ends_at_selected_value,
203				"<option value=\"",
204			)?;
205
206			let value_as_string = &ends_at_selected_value[start_option_location + 15..];
207			Ok(value_as_string.to_owned())
208		}
209		InputType::TableItemWithPrefix(table_prefix) => {
210			for table_item in get_all_table_items(html_response) {
211				let stripped_beginning = table_item
212					.replace("&nbsp;", "")
213					.replace("<br>", "")
214					.replace("<br/>", "")
215					.replace("<br />", "");
216				let trimmed = stripped_beginning.trim();
217				if !trimmed.starts_with(table_prefix) {
218					continue;
219				}
220				let value = trimmed[table_prefix.len()..].trim();
221
222				return Ok(value.to_owned());
223			}
224
225			Err(MionCGIErrors::HtmlResponseNoTableItemWithPrefix(
226				html_response.to_owned(),
227				table_prefix.to_owned().to_owned(),
228			))
229		}
230	}
231}
232
233fn get_all_table_items(body: &str) -> Vec<&str> {
234	let mut table_items = Vec::new();
235
236	let mut response = body;
237	loop {
238		let Some(next_table_start) = response.find("<td") else {
239			break;
240		};
241		let minus_start_of_td = &response[next_table_start + 3..];
242		let Some(tag_end) = minus_start_of_td.find('>') else {
243			break;
244		};
245		let minus_opening_tag = &minus_start_of_td[tag_end + 1..];
246
247		let Some(end_tag_start) = minus_opening_tag.find("</td>") else {
248			break;
249		};
250		let minus_ending_tag = &minus_opening_tag[..end_tag_start];
251		table_items.push(minus_ending_tag);
252
253		response = &response[end_tag_start + 5..];
254	}
255
256	table_items
257}
258
259fn get_html_value(
260	input_id: &str,
261	html_response: &str,
262	haystack: &str,
263	needle: &str,
264) -> Result<usize, MionCGIErrors> {
265	haystack.find(needle).ok_or_else(|| {
266		MionCGIErrors::HtmlResponseMissingTaggedInput(input_id.to_owned(), html_response.to_owned())
267	})
268}
269fn rget_html_value(
270	input_id: &str,
271	html_response: &str,
272	haystack: &str,
273	needle: &str,
274) -> Result<usize, MionCGIErrors> {
275	haystack.rfind(needle).ok_or_else(|| {
276		MionCGIErrors::HtmlResponseMissingTaggedInput(input_id.to_owned(), html_response.to_owned())
277	})
278}
279
280#[cfg(test)]
281mod unit_tests {
282	use super::*;
283	use crate::mion::proto::cgis::CatDevBankSize;
284
285	/// A real world response to the MION setup page.
286	const REAL_WORLD_SETUP_RESPONSE: &str = r##"<HTML>
287<head>
288<meta http-equiv="Content-Type" content="text/html; charset=ASCII">
289<title>MION Board Parameter Settings</title>
290</head>
291<body bgcolor="#FFFFFF" text="#000000">
292<div align="center">
293<h1><u>MION Board Parameter Settings</u></h1>
294<form method="POST" name="param_form" action="setup.cgi">
295<table border=1 cellspacing=1 cellpadding=8>
296 <tr>
297  <td rowspan=9><font size="+1">Network</font></td>
298  <td>IP Address<br><input type="text" name="id_5" maxlength=15 size=25 value="192.168.0.1"></td>
299 </tr>
300 <tr>
301  <td>Subnet Mask<br><input type="text" name="id_6" maxlength=15 size=25 value="255.255.255.0"></td>
302 </tr>
303 <tr>
304  <td>Default Gateway<br><input type="text" name="id_7" maxlength=15 size=25 value="0.0.0.0"></td>
305 </tr>
306 <tr>
307  <td>
308   DHCP<br>
309   <input type="radio" name="id_4" value="1" checked>ON
310   <input type="radio" name="id_4" value="0">OFF
311  </td>
312 </tr>
313 <tr>
314  <td>
315   DNS<br>
316   <input type="radio" name="id_8" value="1">ON
317   <input type="radio" name="id_8" value="0" checked>OFF
318  </td>
319 </tr>
320 <tr>
321  <td>Preferred DNS Server Address<br><input type="text" name="id_9" maxlength=15 size=25 value="0.0.0.0"></td>
322 </tr>
323 <tr>
324  <td>Alternate DNS Server Address<br><input type="text" name="id_10" maxlength=15 size=25 value="0.0.0.0"></td>
325 </tr>
326 <tr>
327  <td>
328   Jumbo Frame<br>
329   <input type="radio" name="id_11" value="1" checked>ON
330   <input type="radio" name="id_11" value="0">OFF
331  </td>
332 </tr>
333 <tr>
334  <td>
335   Host PC IP Address<br>
336   <input type="text" name="id_12" maxlength=15 size=25 value="0.0.0.0">
337  </td>
338 </tr>
339 <tr>
340  <td rowspan=2><font size="+1">SATA</font></td>
341  <td>
342   Internal HDD Bank Size<br>
343   <select name="id_26">
344    <option value="4294967295">&nbsp;
345    <option value="0" selected>25GB
346    <option value="1">5GB
347    <option value="2">9GB
348    <option value="3">12GB
349    <option value="4">14GB
350    <option value="5">16GB
351    <option value="6">18GB
352    <option value="7">21GB
353   </select>
354  </td>
355 </tr>
356 <tr>
357  <td>HDD Bank No.<br><input type="text" name="id_27" size=25 value="0"></td>
358 </tr>
359 <tr>
360  <td><font size="+1">ATAPI Emulator</font></td>
361  <td>Port No. for ATAPI Emulator<br><input type="text" name="id_13" maxlength=5 size=25 value="7974"></td>
362 </tr>
363 <tr>
364  <td><font size="+1">SDIO Printf/Control</font></td>
365  <td>Port No. for SDIO Printf/Control<br><input type="text" name="id_14" maxlength=5 size=25 value="7975"></td>
366 </tr>
367 <tr>
368  <td><font size="+1">SDIO Block Data</font></td>
369  <td>Port No. for SDIO Block Data<br><input type="text" name="id_15" maxlength=5 size=25 value="7976"></td>
370 </tr>
371 <tr>
372  <td><font size="+1">EXI</font></td>
373  <td>Port No. for EXI<br><input type="text" name="id_16" maxlength=5 size=25 value="7977"></td>
374 </tr>
375 <tr>
376  <td><font size="+1">Parameter Space</font></td>
377  <td>Port No. for Parameter Space<br><input type="text" name="id_17" maxlength=5 size=25 value="7978"></td>
378 </tr>
379 <tr>
380  <td><font size="+1">Drive Setting</font></td>
381  <td>
382   Drive Timing Emulation<br>
383   <input type="radio" name="id_33" value="1">enable
384   <input type="radio" name="id_33" value="0" checked>disable
385  </td>
386 </tr>
387 <tr>
388  <td><font size="+1">Operational Mode</font></td>
389  <td>
390   Operational Mode<br>
391   <input type="radio" name="id_32" value="0" checked>CAT-DEV Mode
392   <input type="radio" name="id_32" value="1">H-Reader Mode
393  </td>
394 </tr>
395 <tr>
396  <td rowspan=4><font size="+1">Drive Information</font></td>
397  <td>Product Revision<br><input type="text" name="id_28" maxlength=4 size=25 value="0000">(HEX)</td>
398 </tr>
399 <tr>
400  <td>Vendor Code<br><input type="text" name="id_29" maxlength=2 size=25 value="02">(HEX)</td>
401 </tr>
402 <tr>
403  <td>Device Code<br><input type="text" name="id_30" maxlength=2 size=25 value="06">(HEX)</td>
404 </tr>
405 <tr>
406  <td>   Release Date<br><input type="text" name="id_31" maxlength=8 size=25 value="20100430">(HEX)</td>
407 </tr>
408 <tr>
409  <td rowspan=2><font size="+1">Device Unique</font></td>
410  <td>Machine Name<br><textarea name="id_2" cols=50 rows=3>00-25-5C-BA-5A-00</textarea></td>
411 </tr>
412 <tr>
413  <td>
414   MAC Address<br>
415   &nbsp;&nbsp;&nbsp;00-25-5C-BA-5A-00
416  </td>
417 </tr>
418 <tr>
419  <td colspan=2>
420   <div align="center">
421    <input type="hidden" name="op" value="0">
422    <input type="submit" value="Write">&nbsp;&nbsp;<input type="reset" value="Form Reset">
423   </div>
424  </td>
425 </tr>
426</table>
427</form>
428<form method="POST" name="param_read" action="setup.cgi">
429 <input type="hidden" name="op" value="1">
430  <div align="center">
431   <input type="submit" value="Read">
432  </div>
433</form>
434</div>
435<br>
436<hr>
437<a href="../">Homepage</a>
438</body>
439</HTML>"##;
440
441	#[test]
442	pub fn can_parse_setup_cgis() {
443		let result = parse_response_body_from_setup(REAL_WORLD_SETUP_RESPONSE);
444		assert!(
445			result.is_ok(),
446			"Failed to parse response body from `setup.cgi`:\n  {:?}",
447			result,
448		);
449		let parameters = result.expect("impossible probably, past assert.");
450
451		assert!(
452			parameters.static_ip_address().is_none(),
453			"Expected static ip address to be empty, is using DHCP",
454		);
455		assert_eq!(
456			parameters.raw_static_ip_address(),
457			"192.168.0.1"
458				.parse::<Ipv4Addr>()
459				.expect("Failed to parse test IP Address"),
460		);
461		assert_eq!(
462			parameters.subnet_mask(),
463			"255.255.255.0"
464				.parse::<Ipv4Addr>()
465				.expect("Failed to parse test IP Address"),
466		);
467		assert_eq!(
468			parameters.default_gateway(),
469			"0.0.0.0"
470				.parse::<Ipv4Addr>()
471				.expect("Failed to parse test IP Address"),
472		);
473
474		assert!(parameters.using_dhcp());
475		assert!(!parameters.using_self_managed_dns());
476
477		assert!(
478			parameters.primary_dns().is_none(),
479			"Expected Primary DNS to be empty, is not using self-managed DNS",
480		);
481		assert!(
482			parameters.secondary_dns().is_none(),
483			"Expected Primary DNS to be empty, is not using self-managed DNS",
484		);
485		assert_eq!(
486			parameters.raw_primary_dns(),
487			"0.0.0.0"
488				.parse::<Ipv4Addr>()
489				.expect("Failed to parse static IP Address for testing")
490		);
491		assert_eq!(
492			parameters.raw_secondary_dns(),
493			"0.0.0.0"
494				.parse::<Ipv4Addr>()
495				.expect("Failed to parse static IP Address for testing")
496		);
497
498		assert!(parameters.jumbo_frame());
499
500		assert_eq!(
501			parameters.host_pc_ip_address(),
502			"0.0.0.0"
503				.parse::<Ipv4Addr>()
504				.expect("Failed to parse static IP Address for testing")
505		);
506		assert_eq!(parameters.hdd_bank_size(), CatDevBankSize::TwentyFiveGbs);
507		assert_eq!(parameters.hdd_bank_no(), 0);
508
509		assert_eq!(parameters.atapi_emulator_port(), 7974);
510		assert_eq!(parameters.sdio_printf_port(), 7975);
511		assert_eq!(parameters.sdio_block_port(), 7976);
512		assert_eq!(parameters.exi_port(), 7977);
513		assert_eq!(parameters.parameter_space_port(), 7978);
514
515		assert!(!parameters.drive_timing_emulation_enabled());
516
517		assert!(parameters.is_cat_dev_mode());
518		assert!(!parameters.is_h_reader_mode());
519
520		assert_eq!(parameters.drive_product_revision(), "0000");
521		assert_eq!(parameters.drive_vendor_code(), "02");
522		assert_eq!(parameters.drive_device_code(), "06");
523		assert_eq!(parameters.drive_release_date(), "20100430");
524
525		assert_eq!(parameters.device_name(), "00-25-5C-BA-5A-00");
526		assert_eq!(
527			parameters.mac_address(),
528			"00-25-5C-BA-5A-00"
529				.parse::<MacAddress>()
530				.expect("Failed to parse static MacAddress for tests"),
531		);
532	}
533}