1use crate::{
13 DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord,
14 crypto::hmac_sha256,
15 http::{HttpClient, HttpClientBuilder},
16 utils::txt_chunks_to_text,
17};
18use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
19use chrono::Utc;
20use quick_xml::se::to_string as xml_to_string;
21use serde::{Deserialize, Serialize};
22use std::time::Duration;
23
24const DEFAULT_ENDPOINT: &str = "https://dns.api.nifcloud.com";
25const API_VERSION: &str = "2012-12-12N2013-12-16";
26const XMLNS: &str = "https://route53.amazonaws.com/doc/2012-12-12/";
27
28#[derive(Clone)]
29pub struct NifcloudProvider {
30 client: HttpClient,
31 access_key: String,
32 secret_key: String,
33 endpoint: String,
34}
35
36#[derive(Serialize, Debug)]
37#[serde(rename = "ChangeResourceRecordSetsRequest")]
38struct ChangeRequest {
39 #[serde(rename = "@xmlns")]
40 xmlns: &'static str,
41 #[serde(rename = "ChangeBatch")]
42 change_batch: ChangeBatch,
43}
44
45#[derive(Serialize, Debug)]
46struct ChangeBatch {
47 #[serde(rename = "Comment")]
48 comment: String,
49 #[serde(rename = "Changes")]
50 changes: Changes,
51}
52
53#[derive(Serialize, Debug)]
54struct Changes {
55 #[serde(rename = "Change")]
56 change: Vec<Change>,
57}
58
59#[derive(Serialize, Debug)]
60struct Change {
61 #[serde(rename = "Action")]
62 action: &'static str,
63 #[serde(rename = "ResourceRecordSet")]
64 resource_record_set: ResourceRecordSet,
65}
66
67#[derive(Serialize, Debug)]
68struct ResourceRecordSet {
69 #[serde(rename = "Name")]
70 name: String,
71 #[serde(rename = "Type")]
72 record_type: &'static str,
73 #[serde(rename = "TTL")]
74 ttl: u32,
75 #[serde(rename = "ResourceRecords")]
76 resource_records: ResourceRecords,
77}
78
79#[derive(Serialize, Debug)]
80struct ResourceRecords {
81 #[serde(rename = "ResourceRecord")]
82 resource_record: Vec<ResourceRecord>,
83}
84
85#[derive(Serialize, Debug)]
86struct ResourceRecord {
87 #[serde(rename = "Value")]
88 value: String,
89}
90
91#[derive(Deserialize, Debug)]
92struct ChangeResponse {
93 #[serde(rename = "ChangeInfo")]
94 #[allow(dead_code)]
95 change_info: ChangeInfo,
96}
97
98#[derive(Deserialize, Debug)]
99#[allow(dead_code)]
100struct ChangeInfo {
101 #[serde(rename = "Id")]
102 id: String,
103}
104
105#[derive(Deserialize, Debug)]
106struct ErrorResponse {
107 #[serde(rename = "Error", default)]
108 error: NifcloudError,
109}
110
111#[derive(Deserialize, Debug, Default)]
112struct NifcloudError {
113 #[serde(rename = "Code", default)]
114 code: String,
115 #[serde(rename = "Message", default)]
116 message: String,
117}
118
119#[derive(Deserialize, Debug, Default)]
120struct ListResponse {
121 #[serde(rename = "ResourceRecordSets", default)]
122 resource_record_sets: ListedRecordSets,
123 #[serde(rename = "IsTruncated", default)]
124 is_truncated: String,
125 #[serde(rename = "NextRecordName", default)]
126 next_record_name: Option<String>,
127 #[serde(rename = "NextRecordType", default)]
128 next_record_type: Option<String>,
129 #[serde(rename = "NextRecordIdentifier", default)]
130 next_record_identifier: Option<String>,
131}
132
133#[derive(Deserialize, Debug, Default)]
134struct ListedRecordSets {
135 #[serde(rename = "ResourceRecordSet", default)]
136 resource_record_set: Vec<ListedRecordSet>,
137}
138
139#[derive(Deserialize, Debug, Clone)]
140struct ListedRecordSet {
141 #[serde(rename = "Name", default)]
142 name: String,
143 #[serde(rename = "Type", default)]
144 record_type: String,
145 #[serde(rename = "TTL", default)]
146 ttl: u32,
147 #[serde(rename = "SetIdentifier", default)]
148 set_identifier: Option<String>,
149 #[serde(rename = "ResourceRecords", default)]
150 resource_records: ListedResourceRecords,
151}
152
153#[derive(Deserialize, Debug, Default, Clone)]
154struct ListedResourceRecords {
155 #[serde(rename = "ResourceRecord", default)]
156 resource_record: Vec<ListedResourceRecord>,
157}
158
159#[derive(Deserialize, Debug, Clone)]
160struct ListedResourceRecord {
161 #[serde(rename = "Value", default)]
162 value: String,
163}
164
165impl NifcloudProvider {
166 pub(crate) fn new(
167 access_key: impl AsRef<str>,
168 secret_key: impl AsRef<str>,
169 timeout: Option<Duration>,
170 ) -> crate::Result<Self> {
171 let access_key = access_key.as_ref();
172 let secret_key = secret_key.as_ref();
173 if access_key.is_empty() || secret_key.is_empty() {
174 return Err(Error::Api("Nifcloud credentials missing".into()));
175 }
176 let client = HttpClientBuilder::default()
177 .with_header("Accept", "application/xml")
178 .with_timeout(timeout)
179 .build();
180 Ok(Self {
181 client,
182 access_key: access_key.to_string(),
183 secret_key: secret_key.to_string(),
184 endpoint: DEFAULT_ENDPOINT.to_string(),
185 })
186 }
187
188 #[cfg(test)]
189 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
190 Self {
191 endpoint: endpoint.as_ref().to_string(),
192 ..self
193 }
194 }
195
196 fn signed(&self, request: crate::http::HttpRequest) -> crate::http::HttpRequest {
197 let date = Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
198 let mac = hmac_sha256(self.secret_key.as_bytes(), date.as_bytes());
199 let signature = BASE64_STANDARD.encode(&mac);
200 let auth = format!(
201 "NIFTY3-HTTPS NiftyAccessKeyId={},Algorithm=HmacSHA256,Signature={}",
202 self.access_key, signature
203 );
204 request
205 .set_header("Content-Type", "text/xml; charset=utf-8")
206 .with_header("Date", date)
207 .with_header("X-Nifty-Authorization", auth)
208 }
209
210 pub(crate) async fn set_rrset(
211 &self,
212 name: impl IntoFqdn<'_>,
213 record_type: DnsRecordType,
214 ttl: u32,
215 records: Vec<DnsRecord>,
216 origin: impl IntoFqdn<'_>,
217 ) -> crate::Result<()> {
218 let type_str = dns_type_str(record_type)?;
219 check_record_types(record_type, &records)?;
220 let name_str = name.into_name().to_string();
221 let domain = origin.into_name().to_string();
222 let subdomain_name = normalized_record_name(&name_str, &domain);
223
224 let desired = build_values(record_type, &records)?;
225 let existing = self
226 .list_existing(&domain, &subdomain_name, type_str)
227 .await?;
228
229 let mut changes: Vec<Change> = Vec::new();
230 if let Some(prev) = existing.first() {
231 let current_values: Vec<String> = prev
232 .resource_records
233 .resource_record
234 .iter()
235 .map(|r| r.value.clone())
236 .collect();
237 if prev.ttl == ttl && values_equal(¤t_values, &desired) {
238 return Ok(());
239 }
240 changes.push(Change {
241 action: "DELETE",
242 resource_record_set: ResourceRecordSet {
243 name: subdomain_name.clone(),
244 record_type: type_str,
245 ttl: prev.ttl,
246 resource_records: ResourceRecords {
247 resource_record: current_values
248 .into_iter()
249 .map(|value| ResourceRecord { value })
250 .collect(),
251 },
252 },
253 });
254 }
255
256 if !desired.is_empty() {
257 changes.push(Change {
258 action: "CREATE",
259 resource_record_set: ResourceRecordSet {
260 name: subdomain_name,
261 record_type: type_str,
262 ttl,
263 resource_records: ResourceRecords {
264 resource_record: desired
265 .into_iter()
266 .map(|value| ResourceRecord { value })
267 .collect(),
268 },
269 },
270 });
271 }
272
273 if changes.is_empty() {
274 return Ok(());
275 }
276
277 self.send_change(
278 &domain,
279 ChangeRequest {
280 xmlns: XMLNS,
281 change_batch: ChangeBatch {
282 comment: "Managed by dns-update".into(),
283 changes: Changes { change: changes },
284 },
285 },
286 )
287 .await
288 .map(|_| ())
289 }
290
291 pub(crate) async fn add_to_rrset(
292 &self,
293 name: impl IntoFqdn<'_>,
294 record_type: DnsRecordType,
295 ttl: u32,
296 records: Vec<DnsRecord>,
297 origin: impl IntoFqdn<'_>,
298 ) -> crate::Result<()> {
299 if records.is_empty() {
300 return Ok(());
301 }
302 let type_str = dns_type_str(record_type)?;
303 check_record_types(record_type, &records)?;
304 let name_str = name.into_name().to_string();
305 let domain = origin.into_name().to_string();
306 let subdomain_name = normalized_record_name(&name_str, &domain);
307
308 let new_values = build_values(record_type, &records)?;
309 let existing = self
310 .list_existing(&domain, &subdomain_name, type_str)
311 .await?;
312
313 let mut changes: Vec<Change> = Vec::new();
314 let merged: Vec<String>;
315 let merged_ttl: u32;
316
317 if let Some(prev) = existing.first() {
318 let current_values: Vec<String> = prev
319 .resource_records
320 .resource_record
321 .iter()
322 .map(|r| r.value.clone())
323 .collect();
324 let mut combined = current_values.clone();
325 for value in &new_values {
326 if !combined.iter().any(|v| v == value) {
327 combined.push(value.clone());
328 }
329 }
330 if values_equal(¤t_values, &combined) {
331 return Ok(());
332 }
333 changes.push(Change {
334 action: "DELETE",
335 resource_record_set: ResourceRecordSet {
336 name: subdomain_name.clone(),
337 record_type: type_str,
338 ttl: prev.ttl,
339 resource_records: ResourceRecords {
340 resource_record: current_values
341 .into_iter()
342 .map(|value| ResourceRecord { value })
343 .collect(),
344 },
345 },
346 });
347 merged = combined;
348 merged_ttl = prev.ttl;
349 } else {
350 merged = new_values;
351 merged_ttl = ttl;
352 }
353
354 changes.push(Change {
355 action: "CREATE",
356 resource_record_set: ResourceRecordSet {
357 name: subdomain_name,
358 record_type: type_str,
359 ttl: merged_ttl,
360 resource_records: ResourceRecords {
361 resource_record: merged
362 .into_iter()
363 .map(|value| ResourceRecord { value })
364 .collect(),
365 },
366 },
367 });
368
369 self.send_change(
370 &domain,
371 ChangeRequest {
372 xmlns: XMLNS,
373 change_batch: ChangeBatch {
374 comment: "Managed by dns-update".into(),
375 changes: Changes { change: changes },
376 },
377 },
378 )
379 .await
380 .map(|_| ())
381 }
382
383 pub(crate) async fn remove_from_rrset(
384 &self,
385 name: impl IntoFqdn<'_>,
386 record_type: DnsRecordType,
387 records: Vec<DnsRecord>,
388 origin: impl IntoFqdn<'_>,
389 ) -> crate::Result<()> {
390 if records.is_empty() {
391 return Ok(());
392 }
393 let type_str = dns_type_str(record_type)?;
394 check_record_types(record_type, &records)?;
395 let name_str = name.into_name().to_string();
396 let domain = origin.into_name().to_string();
397 let subdomain_name = normalized_record_name(&name_str, &domain);
398
399 let to_remove = build_values(record_type, &records)?;
400 let existing = self
401 .list_existing(&domain, &subdomain_name, type_str)
402 .await?;
403
404 let Some(prev) = existing.first() else {
405 return Ok(());
406 };
407 let current_values: Vec<String> = prev
408 .resource_records
409 .resource_record
410 .iter()
411 .map(|r| r.value.clone())
412 .collect();
413 let remaining: Vec<String> = current_values
414 .iter()
415 .filter(|v| !to_remove.iter().any(|r| r == *v))
416 .cloned()
417 .collect();
418 if remaining.len() == current_values.len() {
419 return Ok(());
420 }
421
422 let mut changes: Vec<Change> = Vec::new();
423 changes.push(Change {
424 action: "DELETE",
425 resource_record_set: ResourceRecordSet {
426 name: subdomain_name.clone(),
427 record_type: type_str,
428 ttl: prev.ttl,
429 resource_records: ResourceRecords {
430 resource_record: current_values
431 .into_iter()
432 .map(|value| ResourceRecord { value })
433 .collect(),
434 },
435 },
436 });
437 if !remaining.is_empty() {
438 changes.push(Change {
439 action: "CREATE",
440 resource_record_set: ResourceRecordSet {
441 name: subdomain_name,
442 record_type: type_str,
443 ttl: prev.ttl,
444 resource_records: ResourceRecords {
445 resource_record: remaining
446 .into_iter()
447 .map(|value| ResourceRecord { value })
448 .collect(),
449 },
450 },
451 });
452 }
453
454 self.send_change(
455 &domain,
456 ChangeRequest {
457 xmlns: XMLNS,
458 change_batch: ChangeBatch {
459 comment: "Managed by dns-update".into(),
460 changes: Changes { change: changes },
461 },
462 },
463 )
464 .await
465 .map(|_| ())
466 }
467
468 pub(crate) async fn list_rrset(
469 &self,
470 name: impl IntoFqdn<'_>,
471 record_type: DnsRecordType,
472 origin: impl IntoFqdn<'_>,
473 ) -> crate::Result<Vec<DnsRecord>> {
474 let type_str = dns_type_str(record_type)?;
475 let name_str = name.into_name().to_string();
476 let domain = origin.into_name().to_string();
477 let subdomain_name = normalized_record_name(&name_str, &domain);
478
479 let existing = self
480 .list_existing(&domain, &subdomain_name, type_str)
481 .await?;
482 let mut out = Vec::new();
483 for rrset in existing {
484 for record in rrset.resource_records.resource_record {
485 out.push(parse_value(record_type, &record.value)?);
486 }
487 }
488 Ok(out)
489 }
490
491 async fn list_existing(
492 &self,
493 domain: &str,
494 subdomain_name: &str,
495 type_str: &str,
496 ) -> crate::Result<Vec<ListedRecordSet>> {
497 let target_name_a = subdomain_name.to_string();
498 let target_name_b = if subdomain_name == "@" {
499 domain.trim_end_matches('.').to_string()
500 } else {
501 format!("{}.{}", subdomain_name, domain.trim_end_matches('.'))
502 };
503
504 let mut out: Vec<ListedRecordSet> = Vec::new();
505 let mut next_name: Option<String> = Some(target_name_b.clone());
506 let mut next_type: Option<String> = Some(type_str.to_string());
507 let mut next_identifier: Option<String> = None;
508
509 loop {
510 let mut query = String::new();
511 if let Some(n) = next_name.as_ref() {
512 query.push_str(&format!("name={}", urlencode(n)));
513 }
514 if let Some(t) = next_type.as_ref() {
515 if !query.is_empty() {
516 query.push('&');
517 }
518 query.push_str(&format!("type={t}"));
519 }
520 if let Some(i) = next_identifier.as_ref() {
521 if !query.is_empty() {
522 query.push('&');
523 }
524 query.push_str(&format!("identifier={}", urlencode(i)));
525 }
526 let url = if query.is_empty() {
527 format!(
528 "{}/{}/hostedzone/{}/rrset",
529 self.endpoint, API_VERSION, domain
530 )
531 } else {
532 format!(
533 "{}/{}/hostedzone/{}/rrset?{}",
534 self.endpoint, API_VERSION, domain, query
535 )
536 };
537 let response = self.signed(self.client.get(url)).send_raw().await?;
538 if response.contains("<Error>") {
539 let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
540 if let Ok(err) = parsed {
541 return Err(Error::Api(format!(
542 "Nifcloud error {}: {}",
543 err.error.code, err.error.message
544 )));
545 }
546 return Err(Error::Api(format!("Nifcloud error response: {response}")));
547 }
548 let list: ListResponse = quick_xml::de::from_str(&response)
549 .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
550 for rrset in list.resource_record_sets.resource_record_set {
551 if rrset.set_identifier.is_some() {
552 continue;
553 }
554 if rrset.record_type != type_str {
555 continue;
556 }
557 let candidate = rrset.name.trim_end_matches('.');
558 if candidate == target_name_a.trim_end_matches('.')
559 || candidate == target_name_b.trim_end_matches('.')
560 {
561 out.push(rrset);
562 }
563 }
564 if list.is_truncated.eq_ignore_ascii_case("true")
565 && (list.next_record_name.is_some() || list.next_record_identifier.is_some())
566 {
567 next_name = list.next_record_name;
568 next_type = list.next_record_type;
569 next_identifier = list.next_record_identifier;
570 continue;
571 }
572 break;
573 }
574 Ok(out)
575 }
576
577 async fn send_change(&self, domain: &str, body: ChangeRequest) -> crate::Result<String> {
578 let xml_body = xml_to_string(&body)
579 .map_err(|e| Error::Serialize(format!("XML serialization failed: {e}")))?;
580 let payload = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}", xml_body);
581 let url = format!(
582 "{}/{}/hostedzone/{}/rrset",
583 self.endpoint, API_VERSION, domain
584 );
585 let response = self
586 .signed(self.client.post(url).with_raw_body(payload))
587 .send_raw()
588 .await?;
589 if response.contains("<Error>") {
590 let parsed: Result<ErrorResponse, _> = quick_xml::de::from_str(&response);
591 if let Ok(err) = parsed {
592 return Err(Error::Api(format!(
593 "Nifcloud error {}: {}",
594 err.error.code, err.error.message
595 )));
596 }
597 return Err(Error::Api(format!("Nifcloud error response: {response}")));
598 }
599 let _info: ChangeResponse = quick_xml::de::from_str(&response)
600 .map_err(|e| Error::Serialize(format!("XML deserialization failed: {e}")))?;
601 Ok(response)
602 }
603}
604
605fn urlencode(input: &str) -> String {
606 let mut out = String::with_capacity(input.len());
607 for byte in input.bytes() {
608 match byte {
609 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
610 out.push(byte as char);
611 }
612 _ => {
613 out.push_str(&format!("%{byte:02X}"));
614 }
615 }
616 }
617 out
618}
619
620fn normalized_record_name(name: &str, domain: &str) -> String {
621 let unfqdn = name.trim_end_matches('.');
622 let domain = domain.trim_end_matches('.');
623 if unfqdn == domain {
624 "@".to_string()
625 } else if let Some(prefix) = unfqdn.strip_suffix(&format!(".{}", domain)) {
626 prefix.to_string()
627 } else {
628 unfqdn.to_string()
629 }
630}
631
632fn values_equal(a: &[String], b: &[String]) -> bool {
633 if a.len() != b.len() {
634 return false;
635 }
636 let mut a_sorted: Vec<&String> = a.iter().collect();
637 let mut b_sorted: Vec<&String> = b.iter().collect();
638 a_sorted.sort();
639 b_sorted.sort();
640 a_sorted == b_sorted
641}
642
643fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
644 for record in records {
645 if record.as_type() != expected {
646 return Err(Error::Api(format!(
647 "RRSet record type mismatch: expected {}, got {}",
648 expected.as_str(),
649 record.as_type().as_str(),
650 )));
651 }
652 }
653 Ok(())
654}
655
656fn dns_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
657 match record_type {
658 DnsRecordType::A => Ok("A"),
659 DnsRecordType::AAAA => Ok("AAAA"),
660 DnsRecordType::CNAME => Ok("CNAME"),
661 DnsRecordType::NS => Ok("NS"),
662 DnsRecordType::MX => Ok("MX"),
663 DnsRecordType::TXT => Ok("TXT"),
664 DnsRecordType::SRV => Err(Error::Unsupported(
665 "SRV records are not supported by Nifcloud".into(),
666 )),
667 DnsRecordType::CAA => Err(Error::Unsupported(
668 "CAA records are not supported by Nifcloud".into(),
669 )),
670 DnsRecordType::TLSA => Err(Error::Unsupported(
671 "TLSA records are not supported by Nifcloud".into(),
672 )),
673 }
674}
675
676fn build_values(record_type: DnsRecordType, records: &[DnsRecord]) -> crate::Result<Vec<String>> {
677 dns_type_str(record_type)?;
678 let mut out = Vec::with_capacity(records.len());
679 for record in records {
680 out.push(build_value(record)?);
681 }
682 Ok(out)
683}
684
685fn build_value(record: &DnsRecord) -> crate::Result<String> {
686 Ok(match record {
687 DnsRecord::A(addr) => addr.to_string(),
688 DnsRecord::AAAA(addr) => addr.to_string(),
689 DnsRecord::CNAME(target) => target.clone(),
690 DnsRecord::NS(target) => target.clone(),
691 DnsRecord::MX(mx) => format!("{} {}", mx.priority, mx.exchange),
692 DnsRecord::TXT(text) => {
693 let mut out = String::new();
694 txt_chunks_to_text(&mut out, text, " ");
695 out
696 }
697 DnsRecord::SRV(_) => {
698 return Err(Error::Unsupported(
699 "SRV records are not supported by Nifcloud".into(),
700 ));
701 }
702 DnsRecord::CAA(_) => {
703 return Err(Error::Unsupported(
704 "CAA records are not supported by Nifcloud".into(),
705 ));
706 }
707 DnsRecord::TLSA(_) => {
708 return Err(Error::Unsupported(
709 "TLSA records are not supported by Nifcloud".into(),
710 ));
711 }
712 })
713}
714
715fn parse_value(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
716 Ok(match record_type {
717 DnsRecordType::A => DnsRecord::A(
718 value
719 .parse()
720 .map_err(|e| Error::Parse(format!("invalid A value: {e}")))?,
721 ),
722 DnsRecordType::AAAA => DnsRecord::AAAA(
723 value
724 .parse()
725 .map_err(|e| Error::Parse(format!("invalid AAAA value: {e}")))?,
726 ),
727 DnsRecordType::CNAME => DnsRecord::CNAME(value.trim_end_matches('.').to_string()),
728 DnsRecordType::NS => DnsRecord::NS(value.trim_end_matches('.').to_string()),
729 DnsRecordType::MX => {
730 let (prio, exchange) = value
731 .split_once(' ')
732 .ok_or_else(|| Error::Parse(format!("invalid MX value: {value}")))?;
733 DnsRecord::MX(MXRecord {
734 priority: prio
735 .trim()
736 .parse()
737 .map_err(|e| Error::Parse(format!("invalid MX priority: {e}")))?,
738 exchange: exchange.trim().trim_end_matches('.').to_string(),
739 })
740 }
741 DnsRecordType::TXT => DnsRecord::TXT(unquote_txt(value)),
742 DnsRecordType::SRV => {
743 return Err(Error::Unsupported(
744 "SRV records are not supported by Nifcloud".into(),
745 ));
746 }
747 DnsRecordType::CAA => {
748 return Err(Error::Unsupported(
749 "CAA records are not supported by Nifcloud".into(),
750 ));
751 }
752 DnsRecordType::TLSA => {
753 return Err(Error::Unsupported(
754 "TLSA records are not supported by Nifcloud".into(),
755 ));
756 }
757 })
758}
759
760fn unquote_txt(content: &str) -> String {
761 let trimmed = content.trim();
762 let mut out = String::new();
763 let mut chars = trimmed.chars().peekable();
764 let mut in_quotes = false;
765 while let Some(ch) = chars.next() {
766 match ch {
767 '"' => {
768 in_quotes = !in_quotes;
769 }
770 '\\' => {
771 if let Some(next) = chars.next() {
772 out.push(next);
773 }
774 }
775 c if !in_quotes && c.is_whitespace() => {}
776 c => out.push(c),
777 }
778 }
779 out
780}