This repository has no description
23 kB
671 lines
1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use knot_types::{AccountDid, OwnerDid, ParseError, RepoDid, RepoName, RepoRkey, UnixSeconds};
5use zeroize::{Zeroize, ZeroizeOnDrop};
6
7use crate::casbin::AclRoster;
8use crate::source::{
9 CollabRow, MemberRow, RepoRow, SourceDid, SourceKeyType, SourceRepoDid, SourceRepoName,
10 SourceRkey, SourceTimestamp,
11};
12
13#[derive(Debug, thiserror::Error)]
14pub enum MappingError {
15 #[error("repo DID {value} doesn't parse: {source}")]
16 BadRepoDid {
17 value: SourceRepoDid,
18 source: ParseError,
19 },
20 #[error("owner {value} on repo {repo} doesn't parse: {source}")]
21 BadOwnerDid {
22 repo: SourceRepoDid,
23 value: SourceDid,
24 source: ParseError,
25 },
26 #[error("member DID {value} doesn't parse: {source}")]
27 BadMemberDid {
28 value: SourceDid,
29 source: ParseError,
30 },
31 #[error("collaborator DID {value} on repo {repo} doesn't parse: {source}")]
32 BadCollaboratorDid {
33 repo: SourceRepoDid,
34 value: SourceDid,
35 source: ParseError,
36 },
37 #[error("{context} timestamp {value} isn't RFC 3339")]
38 BadTimestamp {
39 context: &'static str,
40 value: SourceTimestamp,
41 },
42 #[error("repo {repo} has a {key_type} signing key of {bytes} bytes instead of 32-byte k256")]
43 BadSigningKey {
44 repo: SourceRepoDid,
45 key_type: SourceKeyType,
46 bytes: usize,
47 },
48 #[error("acl names no server owner")]
49 MissingServerOwner,
50 #[error("acl marks {marker} as owner of {repo} while repo_keys names {owner}")]
51 ConflictingOwnerMarker {
52 repo: SourceRepoDid,
53 marker: SourceDid,
54 owner: SourceDid,
55 },
56 #[error("record key {owner}/{rkey} has no single alias-backed holder")]
57 AmbiguousRkey { owner: OwnerDid, rkey: RepoRkey },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct MappedGrant {
62 pub subject: AccountDid,
63 pub added_by: AccountDid,
64 pub created_at: UnixSeconds,
65 pub unioned: bool,
66}
67
68#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
69pub struct SigningKey([u8; 32]);
70
71impl fmt::Debug for SigningKey {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_tuple("SigningKey").finish_non_exhaustive()
74 }
75}
76
77impl SigningKey {
78 pub fn to_hex(&self) -> zeroize::Zeroizing<String> {
79 zeroize::Zeroizing::new(knot_types::lowercase_hex(&self.0))
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct AdoptRepo {
85 pub source_did: SourceRepoDid,
86 pub did: RepoDid,
87 pub owner: OwnerDid,
88 pub name: RepoName,
89 pub rkey: RepoRkey,
90 pub created_at: UnixSeconds,
91 pub signing_key: SigningKey,
92 pub collaborators: Vec<MappedGrant>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum SkipReason {
97 Name { value: SourceRepoName },
98 Rkey { value: SourceRkey },
99 RkeyCollision { rkey: RepoRkey, winner: RepoDid },
100 NoSourceRepo,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SkippedRepo {
105 pub repo_did: RepoDid,
106 pub reason: SkipReason,
107 pub lost_collaborators: Vec<AccountDid>,
108}
109
110#[derive(Debug, Default, PartialEq, Eq)]
111pub struct Drift {
112 pub acl_only_collaborators: Vec<(SourceRepoDid, SourceDid)>,
113 pub table_only_collaborators: Vec<(SourceRepoDid, SourceDid)>,
114 pub slash_resolved_collaborators: Vec<(SourceRepoDid, SourceDid)>,
115 pub orphan_collaborator_pairs: Vec<(SourceRepoDid, SourceDid)>,
116 pub markerless_owner_repos: Vec<SourceRepoDid>,
117 pub orphan_owner_markers: Vec<SourceRepoDid>,
118 pub extra_owner_markers: Vec<(SourceRepoDid, SourceDid)>,
119 pub acl_only_members: Vec<SourceDid>,
120 pub table_only_members: Vec<SourceDid>,
121 pub slash_owner_markers: u64,
122 pub slash_collab_rows: u64,
123 pub unresolved_slash_forms: Vec<String>,
124}
125
126#[derive(Debug)]
127pub struct Mapping {
128 pub knot_owner: AccountDid,
129 pub members: Vec<MappedGrant>,
130 pub repos: Vec<AdoptRepo>,
131 pub skipped: Vec<SkippedRepo>,
132 pub drift: Drift,
133}
134
135pub fn map_tables(
136 repos: &[RepoRow],
137 rkeys: &BTreeMap<SourceRepoDid, SourceRkey>,
138 members: &[MemberRow],
139 collabs: &[CollabRow],
140 acl: &AclRoster,
141 exists: impl Fn(&SourceRepoDid) -> bool,
142) -> Result<Mapping, MappingError> {
143 let knot_owner = server_owner(acl)?;
144 let repo_index: BTreeMap<&SourceRepoDid, &RepoRow> =
145 repos.iter().map(|repo| (&repo.repo_did, repo)).collect();
146
147 let table_members: Vec<MappedGrant> = members
148 .iter()
149 .filter(|row| row.subject.as_str() != knot_owner.as_str())
150 .map(|row| {
151 Ok(MappedGrant {
152 subject: account(&row.subject).map_err(|source| MappingError::BadMemberDid {
153 value: row.subject.clone(),
154 source,
155 })?,
156 added_by: account(&row.did).map_err(|source| MappingError::BadMemberDid {
157 value: row.did.clone(),
158 source,
159 })?,
160 created_at: unix("knot_members.created", &row.created)?,
161 unioned: false,
162 })
163 })
164 .collect::<Result<_, MappingError>>()?;
165
166 let table_member_dids: BTreeSet<&SourceDid> = members.iter().map(|row| &row.subject).collect();
167 let acl_only_members: Vec<SourceDid> = acl
168 .members
169 .iter()
170 .filter(|did| !table_member_dids.contains(*did) && did.as_str() != knot_owner.as_str())
171 .cloned()
172 .collect();
173 let table_only_members: Vec<SourceDid> = members
174 .iter()
175 .filter(|row| !acl.members.contains(&row.subject))
176 .map(|row| row.subject.clone())
177 .collect();
178 let unioned_members: Vec<MappedGrant> = acl_only_members
179 .iter()
180 .map(|did| {
181 Ok(MappedGrant {
182 subject: account(did).map_err(|source| MappingError::BadMemberDid {
183 value: did.clone(),
184 source,
185 })?,
186 added_by: knot_owner.clone(),
187 // every casbin-only owner is at epoch
188 created_at: UnixSeconds::new(0),
189 unioned: true,
190 })
191 })
192 .collect::<Result<_, MappingError>>()?;
193
194 let (live_collabs, orphan_collabs): (Vec<&CollabRow>, Vec<&CollabRow>) = collabs
195 .iter()
196 .partition(|row| repo_index.contains_key(&row.repo_did));
197 let table_pairs: BTreeSet<(&SourceRepoDid, &SourceDid)> = collabs
198 .iter()
199 .map(|row| (&row.repo_did, &row.subject_did))
200 .collect();
201 let acl_extra: Vec<(&SourceRepoDid, &SourceDid)> = acl
202 .collaborators
203 .iter()
204 .flat_map(|(repo, dids)| dids.iter().map(move |did| (repo, did)))
205 .filter(|(repo, did)| !table_pairs.contains(&(*repo, *did)))
206 .collect();
207 let (acl_only, acl_orphans): (Vec<_>, Vec<_>) = acl_extra
208 .into_iter()
209 .partition(|(repo, _)| repo_index.contains_key(*repo));
210 let orphan_pairs: BTreeSet<(SourceRepoDid, SourceDid)> = acl_orphans
211 .into_iter()
212 .map(|(repo, did)| (repo.clone(), did.clone()))
213 .chain(
214 orphan_collabs
215 .iter()
216 .map(|row| (row.repo_did.clone(), row.subject_did.clone())),
217 )
218 .collect();
219 let table_only_collaborators: Vec<(SourceRepoDid, SourceDid)> = live_collabs
220 .iter()
221 .filter(|row| {
222 [&acl.collaborators, &acl.slash_collaborators]
223 .into_iter()
224 .all(|grants| {
225 grants
226 .get(&row.repo_did)
227 .is_none_or(|dids| !dids.contains(&row.subject_did))
228 })
229 })
230 .map(|row| (row.repo_did.clone(), row.subject_did.clone()))
231 .collect();
232
233 let table_grants = live_collabs.iter().copied().try_fold(
234 BTreeMap::<SourceRepoDid, Vec<MappedGrant>>::new(),
235 |mut grants, row| {
236 let grant = MappedGrant {
237 subject: account(&row.subject_did).map_err(|source| {
238 MappingError::BadCollaboratorDid {
239 repo: row.repo_did.clone(),
240 value: row.subject_did.clone(),
241 source,
242 }
243 })?,
244 added_by: account(&row.added_by_did).map_err(|source| {
245 MappingError::BadCollaboratorDid {
246 repo: row.repo_did.clone(),
247 value: row.added_by_did.clone(),
248 source,
249 }
250 })?,
251 created_at: unix("collaborators.created", &row.created)?,
252 unioned: false,
253 };
254 let entry = grants.entry(row.repo_did.clone()).or_default();
255 if !entry.iter().any(|held| held.subject == grant.subject) {
256 entry.push(grant);
257 }
258 Ok::<_, MappingError>(grants)
259 },
260 )?;
261 let collab_grants = owner_attributed_grants(&acl_only, &repo_index, true, table_grants)?;
262
263 let owners = owner_drift(repos, &repo_index, acl)?;
264 let (adopted, skipped) = classify(repos, rkeys, &collab_grants, exists)?;
265
266 Ok(Mapping {
267 knot_owner,
268 members: table_members.into_iter().chain(unioned_members).collect(),
269 repos: adopted,
270 skipped,
271 drift: Drift {
272 acl_only_collaborators: acl_only
273 .into_iter()
274 .map(|(repo, did)| (repo.clone(), did.clone()))
275 .collect(),
276 table_only_collaborators,
277 slash_resolved_collaborators: acl
278 .slash_collaborators
279 .iter()
280 .flat_map(|(repo, dids)| dids.iter().map(move |did| (repo, did)))
281 .filter(|(repo, did)| !table_pairs.contains(&(*repo, *did)))
282 .map(|(repo, did)| (repo.clone(), did.clone()))
283 .collect(),
284 orphan_collaborator_pairs: orphan_pairs.into_iter().collect(),
285 markerless_owner_repos: owners.markerless,
286 orphan_owner_markers: owners.orphans,
287 extra_owner_markers: owners.extras,
288 acl_only_members,
289 table_only_members,
290 slash_owner_markers: acl.slash_owner_markers,
291 slash_collab_rows: acl.slash_collab_rows,
292 unresolved_slash_forms: acl.unresolved_slash_forms.clone(),
293 },
294 })
295}
296
297pub fn map_preflip(
298 repos: &[RepoRow],
299 rkeys: &BTreeMap<SourceRepoDid, SourceRkey>,
300 members: &[MemberRow],
301 acl: &AclRoster,
302 exists: impl Fn(&SourceRepoDid) -> bool,
303) -> Result<Mapping, MappingError> {
304 let knot_owner = server_owner(acl)?;
305 let repo_index: BTreeMap<&SourceRepoDid, &RepoRow> =
306 repos.iter().map(|repo| (&repo.repo_did, repo)).collect();
307 let enrich: BTreeMap<&SourceDid, &MemberRow> =
308 members.iter().map(|row| (&row.subject, row)).collect();
309 let table_only_members: Vec<SourceDid> = members
310 .iter()
311 .filter(|row| !acl.members.contains(&row.subject))
312 .map(|row| row.subject.clone())
313 .collect();
314
315 let mapped_members: Vec<MappedGrant> = acl
316 .members
317 .iter()
318 .filter(|did| did.as_str() != knot_owner.as_str())
319 .map(|did| {
320 let subject = account(did).map_err(|source| MappingError::BadMemberDid {
321 value: did.clone(),
322 source,
323 })?;
324 match enrich.get(did) {
325 Some(row) => Ok(MappedGrant {
326 subject,
327 added_by: account(&row.did).map_err(|source| MappingError::BadMemberDid {
328 value: row.did.clone(),
329 source,
330 })?,
331 created_at: unix("knot_members.created", &row.created)?,
332 unioned: false,
333 }),
334 None => Ok(MappedGrant {
335 subject,
336 added_by: knot_owner.clone(),
337 created_at: UnixSeconds::new(0),
338 unioned: false,
339 }),
340 }
341 })
342 .collect::<Result<_, MappingError>>()?;
343
344 let combined: BTreeMap<&SourceRepoDid, BTreeSet<&SourceDid>> = acl
345 .collaborators
346 .iter()
347 .chain(acl.slash_collaborators.iter())
348 .flat_map(|(repo, dids)| dids.iter().map(move |did| (repo, did)))
349 .fold(BTreeMap::new(), |mut pairs, (repo, did)| {
350 pairs.entry(repo).or_default().insert(did);
351 pairs
352 });
353 let live = combined
354 .iter()
355 .flat_map(|(repo, dids)| dids.iter().map(move |did| (*repo, *did)));
356 let (resolvable, orphan_pairs): (Vec<_>, Vec<_>) =
357 live.partition(|(repo, _)| repo_index.contains_key(*repo));
358 let collab_grants = owner_attributed_grants(&resolvable, &repo_index, false, BTreeMap::new())?;
359
360 let owners = owner_drift(repos, &repo_index, acl)?;
361 let (adopted, skipped) = classify(repos, rkeys, &collab_grants, exists)?;
362
363 Ok(Mapping {
364 knot_owner,
365 members: mapped_members,
366 repos: adopted,
367 skipped,
368 drift: Drift {
369 orphan_collaborator_pairs: orphan_pairs
370 .into_iter()
371 .map(|(repo, did)| (repo.clone(), did.clone()))
372 .collect(),
373 markerless_owner_repos: owners.markerless,
374 orphan_owner_markers: owners.orphans,
375 extra_owner_markers: owners.extras,
376 table_only_members,
377 slash_owner_markers: acl.slash_owner_markers,
378 slash_collab_rows: acl.slash_collab_rows,
379 unresolved_slash_forms: acl.unresolved_slash_forms.clone(),
380 ..Drift::default()
381 },
382 })
383}
384
385fn owner_attributed_grants(
386 pairs: &[(&SourceRepoDid, &SourceDid)],
387 repo_index: &BTreeMap<&SourceRepoDid, &RepoRow>,
388 unioned: bool,
389 base: BTreeMap<SourceRepoDid, Vec<MappedGrant>>,
390) -> Result<BTreeMap<SourceRepoDid, Vec<MappedGrant>>, MappingError> {
391 pairs
392 .iter()
393 .copied()
394 .try_fold(base, |mut grants, (repo, did)| {
395 let row = repo_index[repo];
396 let grant = MappedGrant {
397 subject: account(did).map_err(|source| MappingError::BadCollaboratorDid {
398 repo: repo.clone(),
399 value: did.clone(),
400 source,
401 })?,
402 added_by: account(&row.owner_did).map_err(|source| MappingError::BadOwnerDid {
403 repo: repo.clone(),
404 value: row.owner_did.clone(),
405 source,
406 })?,
407 created_at: unix("repo_keys.created_at", &row.created_at)?,
408 unioned,
409 };
410 grants.entry(repo.clone()).or_default().push(grant);
411 Ok(grants)
412 })
413}
414
415fn server_owner(acl: &AclRoster) -> Result<AccountDid, MappingError> {
416 let did = acl
417 .server_owner
418 .as_ref()
419 .ok_or(MappingError::MissingServerOwner)?;
420 account(did).map_err(|source| MappingError::BadMemberDid {
421 value: did.clone(),
422 source,
423 })
424}
425
426struct OwnerDrift {
427 markerless: Vec<SourceRepoDid>,
428 orphans: Vec<SourceRepoDid>,
429 extras: Vec<(SourceRepoDid, SourceDid)>,
430}
431
432fn owner_drift(
433 repos: &[RepoRow],
434 repo_index: &BTreeMap<&SourceRepoDid, &RepoRow>,
435 acl: &AclRoster,
436) -> Result<OwnerDrift, MappingError> {
437 repos.iter().try_for_each(|repo| {
438 let conflicting = acl.owner_markers.get(&repo.repo_did).and_then(|markers| {
439 (!markers.contains(&repo.owner_did))
440 .then(|| markers.iter().next().cloned())
441 .flatten()
442 });
443 match conflicting {
444 Some(marker) => Err(MappingError::ConflictingOwnerMarker {
445 repo: repo.repo_did.clone(),
446 marker,
447 owner: repo.owner_did.clone(),
448 }),
449 None => Ok(()),
450 }
451 })?;
452 let markerless = repos
453 .iter()
454 .filter(|repo| !acl.owner_markers.contains_key(&repo.repo_did))
455 .map(|repo| repo.repo_did.clone())
456 .collect();
457 let orphans = acl
458 .owner_markers
459 .keys()
460 .filter(|repo| !repo_index.contains_key(*repo))
461 .cloned()
462 .collect();
463 let extras = repos
464 .iter()
465 .filter_map(|repo| {
466 acl.owner_markers
467 .get(&repo.repo_did)
468 .map(|markers| (repo, markers))
469 })
470 .flat_map(|(repo, markers)| {
471 markers
472 .iter()
473 .filter(move |marker| *marker != &repo.owner_did)
474 .map(move |marker| (repo.repo_did.clone(), marker.clone()))
475 })
476 .collect();
477 Ok(OwnerDrift {
478 markerless,
479 orphans,
480 extras,
481 })
482}
483
484fn classify(
485 repos: &[RepoRow],
486 rkeys: &BTreeMap<SourceRepoDid, SourceRkey>,
487 collab_grants: &BTreeMap<SourceRepoDid, Vec<MappedGrant>>,
488 exists: impl Fn(&SourceRepoDid) -> bool,
489) -> Result<(Vec<AdoptRepo>, Vec<SkippedRepo>), MappingError> {
490 let (adopted, skipped) = repos.iter().try_fold(
491 (Vec::new(), Vec::new()),
492 |(mut adopted, mut skipped), row| {
493 match classify_one(row, rkeys, collab_grants, &exists)? {
494 Ok(repo) => adopted.push(repo),
495 Err(skip) => skipped.push(skip),
496 }
497 Ok::<_, MappingError>((adopted, skipped))
498 },
499 )?;
500 let alias_backed: BTreeSet<RepoDid> = rkeys
501 .keys()
502 .filter_map(|did| RepoDid::new(did.as_str()).ok())
503 .collect();
504 resolve_rkey_collisions(adopted, skipped, &alias_backed)
505}
506
507fn resolve_rkey_collisions(
508 adopted: Vec<AdoptRepo>,
509 skipped: Vec<SkippedRepo>,
510 alias_backed: &BTreeSet<RepoDid>,
511) -> Result<(Vec<AdoptRepo>, Vec<SkippedRepo>), MappingError> {
512 let groups: BTreeMap<(&OwnerDid, &RepoRkey), Vec<usize>> =
513 adopted
514 .iter()
515 .enumerate()
516 .fold(BTreeMap::new(), |mut groups, (index, repo)| {
517 groups
518 .entry((&repo.owner, &repo.rkey))
519 .or_default()
520 .push(index);
521 groups
522 });
523 let losers: BTreeMap<usize, SkippedRepo> = groups
524 .into_iter()
525 .filter(|(_, indices)| indices.len() > 1)
526 .map(|((owner, rkey), indices)| {
527 let backed: Vec<usize> = indices
528 .iter()
529 .copied()
530 .filter(|index| alias_backed.contains(&adopted[*index].did))
531 .collect();
532 match backed.as_slice() {
533 [winner] => Ok(indices
534 .into_iter()
535 .filter(|index| index != &*winner)
536 .map(|index| {
537 (
538 index,
539 SkippedRepo {
540 repo_did: adopted[index].did.clone(),
541 reason: SkipReason::RkeyCollision {
542 rkey: rkey.clone(),
543 winner: adopted[*winner].did.clone(),
544 },
545 lost_collaborators: adopted[index]
546 .collaborators
547 .iter()
548 .map(|grant| grant.subject.clone())
549 .collect(),
550 },
551 )
552 })
553 .collect::<Vec<_>>()),
554 _ => Err(MappingError::AmbiguousRkey {
555 owner: owner.clone(),
556 rkey: rkey.clone(),
557 }),
558 }
559 })
560 .collect::<Result<Vec<_>, MappingError>>()?
561 .into_iter()
562 .flatten()
563 .collect();
564 let (kept, demoted) = adopted.into_iter().enumerate().fold(
565 (Vec::new(), losers),
566 |(mut kept, demoted), (index, repo)| {
567 if !demoted.contains_key(&index) {
568 kept.push(repo);
569 }
570 (kept, demoted)
571 },
572 );
573 Ok((
574 kept,
575 skipped.into_iter().chain(demoted.into_values()).collect(),
576 ))
577}
578
579fn classify_one(
580 row: &RepoRow,
581 rkeys: &BTreeMap<SourceRepoDid, SourceRkey>,
582 collab_grants: &BTreeMap<SourceRepoDid, Vec<MappedGrant>>,
583 exists: &impl Fn(&SourceRepoDid) -> bool,
584) -> Result<Result<AdoptRepo, SkippedRepo>, MappingError> {
585 let did = RepoDid::new(row.repo_did.as_str()).map_err(|source| MappingError::BadRepoDid {
586 value: row.repo_did.clone(),
587 source,
588 })?;
589 let owner =
590 OwnerDid::new(row.owner_did.as_str()).map_err(|source| MappingError::BadOwnerDid {
591 repo: row.repo_did.clone(),
592 value: row.owner_did.clone(),
593 source,
594 })?;
595 let created_at = unix("repo_keys.created_at", &row.created_at)?;
596 let key_bytes: [u8; 32] =
597 row.signing_key
598 .as_bytes()
599 .try_into()
600 .map_err(|_| MappingError::BadSigningKey {
601 repo: row.repo_did.clone(),
602 key_type: row.key_type.clone(),
603 bytes: row.signing_key.as_bytes().len(),
604 })?;
605 if !row.key_type.is_k256() {
606 return Err(MappingError::BadSigningKey {
607 repo: row.repo_did.clone(),
608 key_type: row.key_type.clone(),
609 bytes: row.signing_key.as_bytes().len(),
610 });
611 }
612
613 let skip = |reason: SkipReason| SkippedRepo {
614 repo_did: did.clone(),
615 reason,
616 lost_collaborators: collab_grants
617 .get(&row.repo_did)
618 .map(|grants| grants.iter().map(|grant| grant.subject.clone()).collect())
619 .unwrap_or_default(),
620 };
621 let name = match RepoName::new(row.repo_name.as_str()) {
622 Ok(name) => name,
623 Err(_) => {
624 return Ok(Err(skip(SkipReason::Name {
625 value: row.repo_name.clone(),
626 })));
627 }
628 };
629 let raw_rkey = rkeys
630 .get(&row.repo_did)
631 .map(SourceRkey::as_str)
632 .unwrap_or(row.repo_name.as_str());
633 let rkey = match RepoRkey::new(raw_rkey) {
634 Ok(rkey) => rkey,
635 Err(_) => {
636 return Ok(Err(skip(SkipReason::Rkey {
637 value: SourceRkey::from_column(raw_rkey),
638 })));
639 }
640 };
641 if !exists(&row.repo_did) {
642 return Ok(Err(skip(SkipReason::NoSourceRepo)));
643 }
644
645 Ok(Ok(AdoptRepo {
646 source_did: row.repo_did.clone(),
647 did,
648 owner,
649 name,
650 rkey,
651 created_at,
652 signing_key: SigningKey(key_bytes),
653 collaborators: collab_grants
654 .get(&row.repo_did)
655 .cloned()
656 .unwrap_or_default(),
657 }))
658}
659
660fn account(value: &SourceDid) -> Result<AccountDid, ParseError> {
661 AccountDid::new(value.as_str())
662}
663
664fn unix(context: &'static str, value: &SourceTimestamp) -> Result<UnixSeconds, MappingError> {
665 chrono::DateTime::parse_from_rfc3339(value.as_str())
666 .map(|parsed| UnixSeconds::new(parsed.timestamp()))
667 .map_err(|_| MappingError::BadTimestamp {
668 context,
669 value: value.clone(),
670 })
671}