use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use super::Finding;
use crate::collection::{open_any, AnyFile};
use crate::json::repr_str;
use crate::sample::Sample;
use crate::{Error, Result};
fn file_uri_path(uri: &str, windows: bool) -> Option<String> {
let scheme = uri.get(..5).filter(|s| s.eq_ignore_ascii_case("file:"))?;
let rest = &uri[scheme.len()..];
let (host, path) = match rest.strip_prefix("//") {
Some(authority) => {
let at = authority.find('/').unwrap_or(authority.len());
(&authority[..at], &authority[at..])
}
None if rest.starts_with('/') => ("", rest),
None => return None,
};
let path = percent_decode(path)?;
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
if !windows {
return local.then_some(path);
}
if !local {
return Some(format!(r"\\{host}{}", path.replace('/', r"\")));
}
let drive = path.as_bytes();
let drive_path = drive.len() >= 3
&& drive[0] == b'/'
&& drive[1].is_ascii_alphabetic()
&& matches!(drive[2], b':' | b'|')
&& drive.get(3).is_none_or(|c| *c == b'/');
Some(if drive_path { format!("{}:{}", &path[1..2], &path[3..]) } else { path })
}
fn percent_decode(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = text.get(i + 1..i + 3).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SourceRef {
pub source_id: String,
pub uri: String,
pub sample_key: Option<String>,
pub content_id: String,
pub local_subject_id: Option<String>,
}
impl SourceRef {
pub fn new(uri: impl Into<String>, sample_key: Option<String>, content_id: impl Into<String>) -> SourceRef {
let uri = uri.into();
SourceRef { source_id: String::new(), uri, sample_key, content_id: content_id.into(), local_subject_id: None }
}
pub fn pin(uri: impl Into<String>, sample_key: Option<String>, sample: &Sample) -> Result<SourceRef> {
let content_id = sample
.content_id()?
.ok_or_else(|| Error::invalid("a sample without a content_id cannot be pinned; commit it first"))?;
let mut out = SourceRef::new(uri, sample_key, content_id);
let uncovered = crate::integrity::unattested(&sample.root)?;
if !uncovered.is_empty() {
return Err(Error::Integrity(format!(
"{} cannot be pinned: no line of its content_id binds {} to {} bytes (1.0 §13.2); `medh5 validate \
--level integrity` says why",
out.locator(),
uncovered.join(", "),
if uncovered.len() == 1 { "its" } else { "their" },
)));
}
out.local_subject_id = Some(sample.identity()?.subject_id.clone());
Ok(out)
}
pub fn resolve(&self, base: Option<&Path>) -> PathBuf {
let path = file_uri_path(&self.uri, cfg!(windows)).map_or_else(|| PathBuf::from(&self.uri), PathBuf::from);
match base {
Some(b) if path.is_relative() => b.join(path),
_ => path,
}
}
pub fn locator(&self) -> String {
match &self.sample_key {
Some(k) => format!("{}::{k}", self.uri),
None => self.uri.clone(),
}
}
pub fn open(&self, base: Option<&Path>) -> Result<Sample> {
let path = self.resolve(base);
match open_any(&path, self.sample_key.as_deref())? {
AnyFile::Sample(s) => Ok(s),
AnyFile::Collection(_) => Err(Error::invalid(format!(
"{} is a collection; a source names one of its members with `sample_key`",
repr_str(&self.uri)
))),
}
}
pub fn check(&self, sample: &Sample, deep: bool) -> Result<Vec<Finding>> {
let mut out = Vec::new();
let at = if self.source_id.is_empty() { self.locator() } else { self.source_id.clone() };
let stored = sample.content_id()?;
if stored.as_deref() != Some(self.content_id.as_str()) {
out.push(Finding::new(
"T302",
&at,
format!(
"{} is not the version the reference pins ({}) --- repin it explicitly if the change is meant",
self.locator(),
self.content_id
),
));
return Ok(out);
}
let digests = crate::integrity::collect_digests(&sample.root, &["index"])?;
let uncovered = crate::integrity::unattested(&sample.root)?;
if !uncovered.is_empty() {
out.push(Finding::new(
"T302",
&at,
format!(
"{}: no line of the root binds {} to {} bytes, so the pinned content_id does not cover {}",
self.locator(),
uncovered.join(", "),
if uncovered.len() == 1 { "its" } else { "their" },
if uncovered.len() == 1 { "it" } else { "them" }
),
));
return Ok(out);
}
let algo = crate::integrity::digest::root_algo(&sample.root)?;
let recomputed =
crate::integrity::compute_content_id(&sample.root, &sample.attr_name_map()?, &algo, Some(&digests))?;
if recomputed != self.content_id {
out.push(Finding::new(
"T302",
&at,
format!("{} declares the pinned content_id but its digests recompute to {recomputed}", self.locator()),
));
return Ok(out);
}
let mut failed = Vec::new();
for (path, digest) in &digests {
if !deep && !path.starts_with("clinical/") {
continue;
}
let matches = crate::digest::parse_digest(digest).and_then(|(algo, _)| {
let ds = sample.root.dataset(path)?;
Ok(&crate::integrity::dataset_digest(&ds, path, &algo)? == digest)
});
if !matches.unwrap_or(false) {
failed.push(path.clone());
}
}
if !failed.is_empty() {
failed.sort();
out.push(Finding::new(
"T302",
&at,
format!(
"{}: dataset bytes no longer match their digests ({}), whatever the stored root says",
self.locator(),
failed.join(", ")
),
));
}
Ok(out)
}
pub fn to_json(&self) -> Value {
json!({
"source_id": self.source_id,
"uri": self.uri,
"sample_key": self.sample_key,
"content_id": self.content_id,
"local_subject_id": self.local_subject_id,
})
}
pub fn to_pin_json(&self) -> Value {
json!({"uri": self.uri, "sample_key": self.sample_key, "content_id": self.content_id})
}
pub fn from_json(value: &Value) -> Result<SourceRef> {
let text = |k: &str| value.get(k).and_then(Value::as_str).map(str::to_string);
Ok(SourceRef {
source_id: text("source_id").unwrap_or_default(),
uri: text("uri").ok_or_else(|| Error::invalid("a source names its `uri`"))?,
sample_key: text("sample_key"),
content_id: text("content_id").ok_or_else(|| Error::invalid("a source pins its `content_id`"))?,
local_subject_id: text("local_subject_id"),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn s2_a_file_uri_names_the_path_it_was_made_from() {
for (uri, posix, windows) in [
("file:///data/a%20b.medh5", Some("/data/a b.medh5"), Some("/data/a b.medh5")),
("file://localhost/data/x.medh5", Some("/data/x.medh5"), Some("/data/x.medh5")),
("FILE:/data/%C3%A9.medh5", Some("/data/é.medh5"), Some("/data/é.medh5")),
("file:///C:/data/x.medh5", Some("/C:/data/x.medh5"), Some("C:/data/x.medh5")),
("file:///c|/x.medh5", Some("/c|/x.medh5"), Some("c:/x.medh5")),
("file://server/share/x.medh5", None, Some(r"\\server\share\x.medh5")),
("file:///bad%2", None, None),
("file:///bad%+1", None, None),
("file:relative.medh5", None, None),
("data/x.medh5", None, None),
] {
assert_eq!(file_uri_path(uri, false).as_deref(), posix, "{uri}");
assert_eq!(file_uri_path(uri, true).as_deref(), windows, "{uri}");
}
let at = |uri: &str| SourceRef::new(uri, None, "").resolve(Some(Path::new("/base")));
if cfg!(unix) {
assert_eq!(at("file://localhost/data/x.medh5"), PathBuf::from("/data/x.medh5"));
}
assert_eq!(at("x.medh5"), PathBuf::from("/base/x.medh5"));
}
}