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