Skip to main content

pardarsh_core/
provenance.rs

1//! Provenance: where a record or claim came from and how it was derived.
2//!
3//! A [`SourceRef`] points at an original artifact (a URL, a DOI, a URN) and
4//! carries what is known about it. Unknown metadata is allowed: a report must
5//! not be blocked because a publication date is missing. What *is* required is
6//! honesty about authority and derivation: a user-submitted link is marked as
7//! such, and machine-generated material is never presented as original.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13use sha2::{Digest, Sha256};
14
15use crate::id::SourceId;
16use crate::model::{Attribution, Timestamp, Visibility};
17
18/// URI schemes accepted in source locators. Anything else (notably
19/// `javascript:`, `data:` and `file:`) is rejected at validation.
20pub const ALLOWED_LOCATOR_SCHEMES: &[&str] = &["https", "http", "urn", "doi"];
21
22/// Who stands behind the source artifact.
23#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum SourceAuthority {
26    /// Published or formally recorded by the issuing institution. This is
27    /// evidence of what was published, not proof that its content is true.
28    Official,
29    /// Published by a third party (press, NGO, researcher...).
30    ThirdParty,
31    /// Supplied by a user of the system and not independently checked.
32    UserSubmitted,
33    #[default]
34    Unknown,
35}
36
37/// How the material referenced here relates to the original artifact.
38#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum Derivation {
41    /// The reference is to the original artifact itself.
42    #[default]
43    Original,
44    /// Metadata or content extracted from the original by a described method.
45    Extracted { method: String },
46    /// A human-written summary or interpretation of the original.
47    Summary { author: Option<Attribution> },
48    /// Produced by an automated model; must be reviewable and correctable.
49    MachineGenerated { method: String, reviewed: bool },
50}
51
52/// A reference to a source artifact with provenance metadata.
53#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
54pub struct SourceRef {
55    pub id: SourceId,
56    /// URI of the artifact (`https://...`, `urn:...`, `doi:...`).
57    pub locator: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub title: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub media_type: Option<String>,
62    /// The institution or person that issued the artifact, if known.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub issuer: Option<String>,
65    /// Publication time claimed by or for the source, if known.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub published_at: Option<Timestamp>,
68    /// When the artifact was retrieved/consulted (distinct from publication).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub retrieved_at: Option<Timestamp>,
71    /// Hash of the retrieved bytes, if they were fetched. See [`ContentHash`].
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub content_hash: Option<ContentHash>,
74    #[serde(default)]
75    pub authority: SourceAuthority,
76    #[serde(default)]
77    pub derivation: Derivation,
78    /// Who attached this reference.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub submitted_by: Option<Attribution>,
81    /// A source can be more restricted than the record that cites it
82    /// (for example a private attachment on a public issue).
83    #[serde(default)]
84    pub visibility: Visibility,
85}
86
87impl SourceRef {
88    pub fn new(locator: impl Into<String>) -> Self {
89        Self {
90            id: SourceId::generate(),
91            locator: locator.into(),
92            title: None,
93            media_type: None,
94            issuer: None,
95            published_at: None,
96            retrieved_at: None,
97            content_hash: None,
98            authority: SourceAuthority::Unknown,
99            derivation: Derivation::Original,
100            submitted_by: None,
101            visibility: Visibility::Public,
102        }
103    }
104
105    pub fn title(mut self, t: impl Into<String>) -> Self {
106        self.title = Some(t.into());
107        self
108    }
109
110    pub fn issuer(mut self, i: impl Into<String>) -> Self {
111        self.issuer = Some(i.into());
112        self
113    }
114
115    pub fn authority(mut self, a: SourceAuthority) -> Self {
116        self.authority = a;
117        self
118    }
119
120    pub fn retrieved_at(mut self, t: Timestamp) -> Self {
121        self.retrieved_at = Some(t);
122        self
123    }
124
125    pub fn published_at(mut self, t: Timestamp) -> Self {
126        self.published_at = Some(t);
127        self
128    }
129
130    pub fn submitted_by(mut self, a: Attribution) -> Self {
131        self.submitted_by = Some(a);
132        self
133    }
134
135    pub fn visibility(mut self, v: Visibility) -> Self {
136        self.visibility = v;
137        self
138    }
139
140    pub fn derivation(mut self, d: Derivation) -> Self {
141        self.derivation = d;
142        self
143    }
144
145    /// The URI scheme of the locator, lowercased.
146    pub fn scheme(&self) -> Option<String> {
147        let (scheme, _) = self.locator.split_once(':')?;
148        Some(scheme.to_ascii_lowercase())
149    }
150}
151
152/// A content hash with an explicit algorithm prefix: `sha256:<64 hex>`.
153///
154/// Semantics: for source artifacts it is the hash of the exact retrieved
155/// bytes; for records and bundles it is the hash of the canonical JSON
156/// serialization defined in [`crate::interchange::canonical_json`].
157#[derive(Clone, Debug, PartialEq, Eq, Hash)]
158pub struct ContentHash(String);
159
160impl ContentHash {
161    pub fn sha256(bytes: &[u8]) -> Self {
162        Self(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
163    }
164
165    pub fn as_str(&self) -> &str {
166        &self.0
167    }
168}
169
170impl fmt::Display for ContentHash {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(&self.0)
173    }
174}
175
176impl FromStr for ContentHash {
177    type Err = String;
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        match s.strip_prefix("sha256:") {
180            Some(h) if h.len() == 64 && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) => {
181                Ok(Self(s.to_string()))
182            }
183            _ => Err(format!("invalid content hash {s:?}: expected sha256:<64 lowercase hex>")),
184        }
185    }
186}
187
188impl Serialize for ContentHash {
189    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
190        s.serialize_str(&self.0)
191    }
192}
193
194impl<'de> Deserialize<'de> for ContentHash {
195    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
196        let s = String::deserialize(d)?;
197        s.parse().map_err(serde::de::Error::custom)
198    }
199}