1#![cfg(any(feature = "ring", feature = "aws-lc-rs"))]
13
14use crate::crypto::sha256_digest;
15use crate::http::{body_snippet, try_build_client};
16use crate::jwt::{parse_rsa_pkcs8_pem, rsa_sha256_sign};
17use crate::utils::split_caa_value;
18use crate::utils::strip_trailing_dot;
19use crate::utils::{parse_mx, parse_srv, parse_tlsa};
20use crate::utils::{txt_chunks_to_text, validate_region};
21use crate::{CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, Result};
22use encodify::base64::STANDARD as B64;
23use jiff::Timestamp;
24use reqwest::header::{HeaderMap, HeaderValue};
25use reqwest::{Client, Method};
26use serde::{Deserialize, Serialize};
27use std::net::AddrParseError;
28use std::sync::Arc;
29use std::time::Duration;
30
31#[cfg(feature = "ring")]
32use ring::signature::RsaKeyPair;
33
34#[cfg(all(feature = "aws-lc-rs", not(feature = "ring")))]
35use aws_lc_rs::signature::RsaKeyPair;
36
37const RETRIES: u32 = 3;
38const PAGE_LIMIT: u32 = 1000;
39
40#[derive(Debug, Clone)]
41pub struct OracleCloudConfig {
42 pub tenancy_ocid: String,
43 pub user_ocid: String,
44 pub fingerprint: String,
45 pub private_key_pem: String,
46 pub private_key_password: Option<String>,
47 pub region: String,
48 pub compartment_ocid: String,
49 pub request_timeout: Option<Duration>,
50}
51
52#[derive(Clone)]
53pub struct OracleCloudProvider {
54 client: Client,
55 config: OracleCloudConfig,
56 key_pair: Arc<RsaKeyPair>,
57 endpoint: String,
58}
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
61struct OciRecord {
62 domain: String,
63 rtype: String,
64 rdata: String,
65 ttl: u32,
66 #[serde(rename = "isProtected", skip_serializing_if = "Option::is_none")]
67 is_protected: Option<bool>,
68 #[serde(rename = "recordHash", skip_serializing_if = "Option::is_none")]
69 record_hash: Option<String>,
70}
71
72#[derive(Debug, Serialize)]
73struct UpdateRecordsRequest {
74 items: Vec<OciRecord>,
75}
76
77#[derive(Debug, Serialize)]
78struct PatchRecordsRequest {
79 items: Vec<PatchOperation>,
80}
81
82#[derive(Debug, Serialize)]
83struct PatchOperation {
84 operation: &'static str,
85 rdata: String,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 ttl: Option<u32>,
88}
89
90#[derive(Debug, Deserialize)]
91struct RecordCollection {
92 items: Vec<OciRecord>,
93}
94
95#[derive(Debug, Deserialize)]
96struct Zone {
97 name: String,
98 id: String,
99}
100
101impl OracleCloudProvider {
102 pub(crate) fn new(config: OracleCloudConfig) -> Result<Self> {
103 if config.tenancy_ocid.is_empty() {
104 return Err(Error::Client("tenancy_ocid is required".into()));
105 }
106 if config.user_ocid.is_empty() {
107 return Err(Error::Client("user_ocid is required".into()));
108 }
109 if config.fingerprint.is_empty() {
110 return Err(Error::Client("fingerprint is required".into()));
111 }
112 if config.region.is_empty() {
113 return Err(Error::Client("region is required".into()));
114 }
115 validate_region(&config.region)?;
116 if config.compartment_ocid.is_empty() {
117 return Err(Error::Client("compartment_ocid is required".into()));
118 }
119 if config
120 .private_key_password
121 .as_ref()
122 .is_some_and(|p| !p.is_empty())
123 {
124 return Err(Error::Unsupported(
125 "OCI private keys with a passphrase are not supported".into(),
126 ));
127 }
128
129 let key_pair = parse_rsa_pkcs8_pem(&config.private_key_pem)
130 .map_err(|e| Error::Client(format!("Failed to parse OCI private key: {}", e)))?;
131
132 let endpoint = format!("https://dns.{}.oraclecloud.com", config.region);
133 let client = try_build_client(Some(
134 config.request_timeout.unwrap_or(Duration::from_secs(30)),
135 ))?;
136
137 Ok(Self {
138 client,
139 config,
140 key_pair: Arc::new(key_pair),
141 endpoint,
142 })
143 }
144
145 #[cfg(test)]
146 pub(crate) fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
147 self.endpoint = endpoint.into().trim_end_matches('/').to_string();
148 self
149 }
150
151 fn key_id(&self) -> String {
152 format!(
153 "{}/{}/{}",
154 self.config.tenancy_ocid, self.config.user_ocid, self.config.fingerprint
155 )
156 }
157
158 fn sign_request(&self, method: &Method, url: &str, body: Option<&str>) -> Result<HeaderMap> {
159 let parsed = reqwest::Url::parse(url)
160 .map_err(|e| Error::Client(format!("Failed to parse URL {}: {}", url, e)))?;
161 let host = parsed
162 .host_str()
163 .ok_or_else(|| Error::Client(format!("URL missing host: {}", url)))?
164 .to_string();
165 let host_header = if let Some(port) = parsed.port() {
166 format!("{}:{}", host, port)
167 } else {
168 host.clone()
169 };
170 let mut path_and_query = parsed.path().to_string();
171 if let Some(q) = parsed.query() {
172 path_and_query.push('?');
173 path_and_query.push_str(q);
174 }
175
176 let method_lower = method.as_str().to_lowercase();
177 let date = Timestamp::now()
178 .strftime("%a, %d %b %Y %H:%M:%S GMT")
179 .to_string();
180
181 let mut signed_pairs: Vec<(String, String)> = Vec::new();
182 signed_pairs.push((
183 "(request-target)".to_string(),
184 format!("{} {}", method_lower, path_and_query),
185 ));
186 signed_pairs.push(("host".to_string(), host_header.clone()));
187 signed_pairs.push(("date".to_string(), date.clone()));
188
189 let needs_body_headers = matches!(*method, Method::POST | Method::PUT | Method::PATCH);
190 let body_bytes = body.unwrap_or("").as_bytes();
191 let content_sha256 = B64.encode(sha256_digest(body_bytes));
192 let content_length = body_bytes.len().to_string();
193 if needs_body_headers {
194 signed_pairs.push(("x-content-sha256".to_string(), content_sha256.clone()));
195 signed_pairs.push(("content-type".to_string(), "application/json".to_string()));
196 signed_pairs.push(("content-length".to_string(), content_length.clone()));
197 }
198
199 let signing_string = signed_pairs
200 .iter()
201 .map(|(k, v)| format!("{}: {}", k, v))
202 .collect::<Vec<_>>()
203 .join("\n");
204 let signature = rsa_sha256_sign(&self.key_pair, signing_string.as_bytes())
205 .map_err(|e| Error::Client(format!("Failed to sign request: {}", e)))?;
206 let signature_b64 = B64.display(&signature);
207
208 let headers_list = signed_pairs
209 .iter()
210 .map(|(k, _)| k.as_str())
211 .collect::<Vec<_>>()
212 .join(" ");
213 let authorization = format!(
214 "Signature version=\"1\",keyId=\"{}\",algorithm=\"rsa-sha256\",headers=\"{}\",signature=\"{}\"",
215 self.key_id(),
216 headers_list,
217 signature_b64,
218 );
219
220 let mut headers = HeaderMap::new();
221 headers.insert(
222 "host",
223 HeaderValue::from_str(&host_header)
224 .map_err(|e| Error::Client(format!("Invalid host header: {}", e)))?,
225 );
226 headers.insert(
227 "date",
228 HeaderValue::from_str(&date)
229 .map_err(|e| Error::Client(format!("Invalid date header: {}", e)))?,
230 );
231 headers.insert(
232 "authorization",
233 HeaderValue::from_str(&authorization)
234 .map_err(|e| Error::Client(format!("Invalid authorization header: {}", e)))?,
235 );
236 if needs_body_headers {
237 headers.insert(
238 "x-content-sha256",
239 HeaderValue::from_str(&content_sha256)
240 .map_err(|e| Error::Client(format!("Invalid x-content-sha256: {}", e)))?,
241 );
242 headers.insert("content-type", HeaderValue::from_static("application/json"));
243 headers.insert(
244 "content-length",
245 HeaderValue::from_str(&content_length)
246 .map_err(|e| Error::Client(format!("Invalid content-length: {}", e)))?,
247 );
248 }
249
250 Ok(headers)
251 }
252
253 async fn send_signed(
254 &self,
255 method: Method,
256 url: &str,
257 body: Option<String>,
258 ) -> Result<(reqwest::StatusCode, String, HeaderMap)> {
259 let mut attempts: u32 = 0;
260 loop {
261 let headers = self.sign_request(&method, url, body.as_deref())?;
262 let mut request = self.client.request(method.clone(), url).headers(headers);
263 if let Some(b) = body.as_ref() {
264 request = request.body(b.clone());
265 }
266 let response = request
267 .send()
268 .await
269 .map_err(|e| Error::Api(format!("Failed to send request to {}: {}", url, e)))?;
270 let status = response.status();
271 let response_headers = response.headers().clone();
272
273 if status.as_u16() == 429 && attempts < RETRIES {
274 let retry_after = response_headers
275 .get("retry-after")
276 .and_then(|v| v.to_str().ok())
277 .and_then(|s| s.parse::<u64>().ok())
278 .unwrap_or(1);
279 tokio::time::sleep(Duration::from_secs(retry_after)).await;
280 attempts += 1;
281 continue;
282 }
283
284 let text = response
285 .text()
286 .await
287 .map_err(|e| Error::Api(format!("Failed to read response body: {}", e)))?;
288 return Ok((status, text, response_headers));
289 }
290 }
291
292 fn record_to_rdata(record: &DnsRecord) -> Result<(String, String)> {
293 let (rtype, rdata) = match record {
294 DnsRecord::A(ip) => ("A".to_string(), ip.to_string()),
295 DnsRecord::AAAA(ip) => ("AAAA".to_string(), ip.to_string()),
296 DnsRecord::CNAME(c) => ("CNAME".to_string(), format_target(c)),
297 DnsRecord::NS(n) => ("NS".to_string(), format_target(n)),
298 DnsRecord::MX(mx) => (
299 "MX".to_string(),
300 format!("{} {}", mx.priority, format_target(&mx.exchange)),
301 ),
302 DnsRecord::TXT(txt) => {
303 let mut rdata = String::new();
304 txt_chunks_to_text(&mut rdata, txt, " ");
305 ("TXT".to_string(), rdata)
306 }
307 DnsRecord::SRV(srv) => (
308 "SRV".to_string(),
309 format!(
310 "{} {} {} {}",
311 srv.priority,
312 srv.weight,
313 srv.port,
314 format_target(&srv.target)
315 ),
316 ),
317 DnsRecord::CAA(caa) => {
318 let (flags, tag, value) = caa.clone().decompose();
319 (
320 "CAA".to_string(),
321 format!("{} {} \"{}\"", flags, tag, value),
322 )
323 }
324 DnsRecord::TLSA(tlsa) => ("TLSA".to_string(), tlsa.to_string()),
325 };
326 Ok((rtype, rdata))
327 }
328
329 async fn resolve_zone(&self, origin: &str) -> Result<String> {
330 let trimmed = origin.trim_end_matches('.');
331 let url = format!(
332 "{}/20180115/zones?compartmentId={}&name={}",
333 self.endpoint,
334 urlencode(&self.config.compartment_ocid),
335 urlencode(trimmed),
336 );
337 let (status, body, _) = self.send_signed(Method::GET, &url, None).await?;
338 if !status.is_success() {
339 return Err(map_error(status, &body));
340 }
341 let zones: Vec<Zone> = serde_json::from_str(&body)
342 .map_err(|e| Error::Serialize(format!("Failed to parse zones list: {}", e)))?;
343 zones
344 .into_iter()
345 .find(|z| z.name.trim_end_matches('.') == trimmed)
346 .map(|z| z.id)
347 .ok_or(Error::NotFound)
348 }
349
350 fn records_url(&self, zone_id: &str, domain: &str, rtype: &str) -> String {
351 format!(
352 "{}/20180115/zones/{}/records/{}/{}?compartmentId={}",
353 self.endpoint,
354 urlencode(zone_id),
355 urlencode(domain),
356 urlencode(rtype),
357 urlencode(&self.config.compartment_ocid),
358 )
359 }
360
361 fn records_url_paged(
362 &self,
363 zone_id: &str,
364 domain: &str,
365 rtype: &str,
366 page: Option<&str>,
367 ) -> String {
368 let mut url = format!(
369 "{}/20180115/zones/{}/records/{}/{}?compartmentId={}&limit={}",
370 self.endpoint,
371 urlencode(zone_id),
372 urlencode(domain),
373 urlencode(rtype),
374 urlencode(&self.config.compartment_ocid),
375 PAGE_LIMIT,
376 );
377 if let Some(page) = page {
378 url.push_str("&page=");
379 url.push_str(&urlencode(page));
380 }
381 url
382 }
383
384 async fn get_records(
385 &self,
386 zone_id: &str,
387 domain: &str,
388 rtype: &str,
389 ) -> Result<Vec<OciRecord>> {
390 let mut all: Vec<OciRecord> = Vec::new();
391 let mut next_page: Option<String> = None;
392 loop {
393 let url = self.records_url_paged(zone_id, domain, rtype, next_page.as_deref());
394 let (status, body, headers) = self.send_signed(Method::GET, &url, None).await?;
395 if status.as_u16() == 404 {
396 return Ok(Vec::new());
397 }
398 if !status.is_success() {
399 return Err(map_error(status, &body));
400 }
401 let collection: RecordCollection = serde_json::from_str(&body)
402 .map_err(|e| Error::Serialize(format!("Failed to parse records: {}", e)))?;
403 all.extend(collection.items);
404 next_page = headers
405 .get("opc-next-page")
406 .and_then(|v| v.to_str().ok())
407 .map(|s| s.to_string());
408 if next_page.is_none() {
409 return Ok(all);
410 }
411 }
412 }
413
414 async fn put_records(
415 &self,
416 zone_id: &str,
417 domain: &str,
418 rtype: &str,
419 items: Vec<OciRecord>,
420 ) -> Result<()> {
421 let url = self.records_url(zone_id, domain, rtype);
422 let request = UpdateRecordsRequest { items };
423 let body = serde_json::to_string(&request)
424 .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
425 let (status, response_body, _) = self.send_signed(Method::PUT, &url, Some(body)).await?;
426 if !status.is_success() {
427 return Err(map_error(status, &response_body));
428 }
429 Ok(())
430 }
431
432 async fn patch_records(
433 &self,
434 zone_id: &str,
435 domain: &str,
436 rtype: &str,
437 items: Vec<PatchOperation>,
438 ) -> Result<()> {
439 let url = self.records_url(zone_id, domain, rtype);
440 let request = PatchRecordsRequest { items };
441 let body = serde_json::to_string(&request)
442 .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
443 let (status, response_body, _) = self.send_signed(Method::PATCH, &url, Some(body)).await?;
444 if !status.is_success() {
445 return Err(map_error(status, &response_body));
446 }
447 Ok(())
448 }
449
450 async fn delete_rrset(&self, zone_id: &str, domain: &str, rtype: &str) -> Result<()> {
451 let url = self.records_url(zone_id, domain, rtype);
452 let (status, body, _) = self.send_signed(Method::DELETE, &url, None).await?;
453 if status.as_u16() == 404 {
454 return Ok(());
455 }
456 if !status.is_success() {
457 return Err(map_error(status, &body));
458 }
459 Ok(())
460 }
461
462 pub(crate) async fn set_rrset(
463 &self,
464 name: impl IntoFqdn<'_>,
465 record_type: DnsRecordType,
466 ttl: u32,
467 records: Vec<DnsRecord>,
468 origin: impl IntoFqdn<'_>,
469 ) -> Result<()> {
470 check_record_types(record_type, &records)?;
471 let name = name.into_name().to_string();
472 let origin = origin.into_name().to_string();
473 let zone_id = self.resolve_zone(&origin).await?;
474 let rtype = record_type.as_str();
475
476 if records.is_empty() {
477 return self.delete_rrset(&zone_id, &name, rtype).await;
478 }
479
480 let mut items = Vec::with_capacity(records.len());
481 for record in records {
482 let (_, rdata) = Self::record_to_rdata(&record)?;
483 items.push(OciRecord {
484 domain: name.clone(),
485 rtype: rtype.to_string(),
486 rdata,
487 ttl,
488 is_protected: None,
489 record_hash: None,
490 });
491 }
492 self.put_records(&zone_id, &name, rtype, items).await
493 }
494
495 pub(crate) async fn add_to_rrset(
496 &self,
497 name: impl IntoFqdn<'_>,
498 record_type: DnsRecordType,
499 ttl: u32,
500 records: Vec<DnsRecord>,
501 origin: impl IntoFqdn<'_>,
502 ) -> Result<()> {
503 check_record_types(record_type, &records)?;
504 if records.is_empty() {
505 return Ok(());
506 }
507 let name = name.into_name().to_string();
508 let origin = origin.into_name().to_string();
509 let zone_id = self.resolve_zone(&origin).await?;
510 let rtype = record_type.as_str();
511
512 let existing = self.get_records(&zone_id, &name, rtype).await?;
513 let mut items = Vec::with_capacity(records.len());
514 for record in records {
515 let (_, rdata) = Self::record_to_rdata(&record)?;
516 if existing
517 .iter()
518 .any(|e| e.rtype.eq_ignore_ascii_case(rtype) && e.rdata == rdata)
519 {
520 continue;
521 }
522 items.push(PatchOperation {
523 operation: "ADD",
524 rdata,
525 ttl: Some(ttl),
526 });
527 }
528 if items.is_empty() {
529 return Ok(());
530 }
531 self.patch_records(&zone_id, &name, rtype, items).await
532 }
533
534 pub(crate) async fn remove_from_rrset(
535 &self,
536 name: impl IntoFqdn<'_>,
537 record_type: DnsRecordType,
538 records: Vec<DnsRecord>,
539 origin: impl IntoFqdn<'_>,
540 ) -> Result<()> {
541 check_record_types(record_type, &records)?;
542 if records.is_empty() {
543 return Ok(());
544 }
545 let name = name.into_name().to_string();
546 let origin = origin.into_name().to_string();
547 let zone_id = self.resolve_zone(&origin).await?;
548 let rtype = record_type.as_str();
549
550 let mut items = Vec::with_capacity(records.len());
551 for record in records {
552 let (_, rdata) = Self::record_to_rdata(&record)?;
553 items.push(PatchOperation {
554 operation: "REMOVE",
555 rdata,
556 ttl: None,
557 });
558 }
559 match self.patch_records(&zone_id, &name, rtype, items).await {
560 Ok(()) => Ok(()),
561 Err(Error::NotFound) => Ok(()),
562 Err(e) => Err(e),
563 }
564 }
565
566 pub(crate) async fn list_rrset(
567 &self,
568 name: impl IntoFqdn<'_>,
569 record_type: DnsRecordType,
570 origin: impl IntoFqdn<'_>,
571 ) -> Result<Vec<DnsRecord>> {
572 let name = name.into_name().to_string();
573 let origin = origin.into_name().to_string();
574 let zone_id = match self.resolve_zone(&origin).await {
575 Ok(id) => id,
576 Err(Error::NotFound) => return Ok(Vec::new()),
577 Err(e) => return Err(e),
578 };
579 let rtype = record_type.as_str();
580 let items = self.get_records(&zone_id, &name, rtype).await?;
581 let mut out = Vec::with_capacity(items.len());
582 for item in items {
583 if !item.rtype.eq_ignore_ascii_case(rtype) {
584 continue;
585 }
586 out.push(parse_rdata(record_type, &item.rdata)?);
587 }
588 Ok(out)
589 }
590}
591
592fn format_target(value: &str) -> String {
593 format!("{}.", value.trim_end_matches('.'))
594}
595
596fn urlencode(value: &str) -> String {
597 serde_urlencoded::to_string([("v", value)])
598 .ok()
599 .and_then(|s| s.strip_prefix("v=").map(str::to_string))
600 .unwrap_or_else(|| value.to_string())
601}
602
603fn map_error(status: reqwest::StatusCode, body: &str) -> Error {
604 match status.as_u16() {
605 400 => Error::BadRequest,
606 401 | 403 => Error::Unauthorized,
607 404 => Error::NotFound,
608 _ => Error::Api(format!(
609 "Oracle Cloud DNS error {}: {}",
610 status,
611 body_snippet(body)
612 )),
613 }
614}
615
616fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> Result<()> {
617 for r in records {
618 if r.as_type() != expected {
619 return Err(Error::Api(format!(
620 "RRSet record type mismatch: expected {}, got {}",
621 expected.as_str(),
622 r.as_type().as_str(),
623 )));
624 }
625 }
626 Ok(())
627}
628
629fn parse_rdata(record_type: DnsRecordType, value: &str) -> Result<DnsRecord> {
630 Ok(match record_type {
631 DnsRecordType::A => DnsRecord::A(value.parse().map_err(|e: AddrParseError| {
632 Error::Parse(format!("invalid A value '{value}': {e}"))
633 })?),
634 DnsRecordType::AAAA => DnsRecord::AAAA(value.parse().map_err(|e: AddrParseError| {
635 Error::Parse(format!("invalid AAAA value '{value}': {e}"))
636 })?),
637 DnsRecordType::CNAME => DnsRecord::CNAME(strip_trailing_dot(value).to_string()),
638 DnsRecordType::NS => DnsRecord::NS(strip_trailing_dot(value).to_string()),
639 DnsRecordType::MX => parse_mx(value)?,
640 DnsRecordType::TXT => DnsRecord::TXT(parse_txt(value)),
641 DnsRecordType::SRV => parse_srv(value)?,
642 DnsRecordType::TLSA => parse_tlsa(value)?,
643 DnsRecordType::CAA => parse_caa(value)?,
644 })
645}
646
647fn parse_txt(value: &str) -> String {
648 let trimmed = value.trim();
649 let mut out = String::with_capacity(trimmed.len());
650 let mut bytes = trimmed.bytes().peekable();
651 while let Some(&b) = bytes.peek() {
652 if b != b'"' {
653 bytes.next();
654 continue;
655 }
656 bytes.next();
657 loop {
658 match bytes.next() {
659 Some(b'"') => break,
660 Some(b'\\') => {
661 if let Some(next) = bytes.next() {
662 out.push(next as char);
663 }
664 }
665 Some(other) => out.push(other as char),
666 None => break,
667 }
668 }
669 }
670 if out.is_empty() && !trimmed.is_empty() && !trimmed.starts_with('"') {
671 return trimmed.to_string();
672 }
673 out
674}
675
676fn parse_caa(value: &str) -> Result<DnsRecord> {
677 let mut parts = value.splitn(3, char::is_whitespace);
678 let flags: u8 = parts
679 .next()
680 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
681 .parse()
682 .map_err(|e| Error::Parse(format!("invalid CAA flags in '{value}': {e}")))?;
683 let tag = parts
684 .next()
685 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
686 .to_ascii_lowercase();
687 let raw_value = parts
688 .next()
689 .ok_or_else(|| Error::Parse(format!("invalid CAA value '{value}'")))?
690 .trim();
691 let unquoted = raw_value
692 .strip_prefix('"')
693 .and_then(|s| s.strip_suffix('"'))
694 .map(|s| s.replace("\\\"", "\""))
695 .unwrap_or_else(|| raw_value.to_string());
696
697 let issuer_critical = flags & 0x80 != 0;
698 match tag.as_str() {
699 "issue" => {
700 let (name, options) = split_caa_value(&unquoted);
701 Ok(DnsRecord::CAA(CAARecord::Issue {
702 issuer_critical,
703 name,
704 options,
705 }))
706 }
707 "issuewild" => {
708 let (name, options) = split_caa_value(&unquoted);
709 Ok(DnsRecord::CAA(CAARecord::IssueWild {
710 issuer_critical,
711 name,
712 options,
713 }))
714 }
715 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
716 issuer_critical,
717 url: unquoted,
718 })),
719 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
720 }
721}