///|
fn issue(
out : Array[Diagnostic],
code : String,
path : String,
message : String,
warning? : Bool = false,
) -> Unit {
out.push({
severity: if warning {
Warning
} else {
Error
},
code,
path,
message,
})
}
///|
fn manifest_name(path : String, tag : Bool) -> Bool {
path.has_prefix(if tag { "tagmanifest-" } else { "manifest-" }) &&
path.has_suffix(".txt") &&
!path.contains("/")
}
///|
pub fn validate(view : BagView) -> Report {
let diagnostics : Array[Diagnostic] = []
let files : Map[String, FileRecord] = Map([])
let payload : Array[FileRecord] = []
let payload_names : Array[String] = []
let tag_names : Array[String] = []
let mut bytes = 0L
let folded : Map[String, String] = Map([])
for file in view.files {
check_path(file.path) catch {
Invalid(message) => issue(diagnostics, "unsafe_path", file.path, message)
}
if files.contains(file.path) {
issue(
diagnostics,
"duplicate_file",
file.path,
"duplicate inventory entry",
)
}
files[file.path] = file
let lower = file.path[:].to_lower().to_owned()
match folded.get(lower) {
Some(other) if other != file.path =>
issue(
diagnostics,
"case_collision",
file.path,
"case collision with " + other,
warning=true,
)
_ => ()
}
folded[lower] = file.path
if file.size < 0 {
issue(diagnostics, "invalid_size", file.path, "negative byte length")
}
if file.path.has_prefix("data/") {
payload.push(file)
if file.size >= 0 && bytes <= 9223372036854775807L - file.size {
bytes = bytes + file.size
} else {
issue(
diagnostics,
"size_overflow",
file.path,
"invalid total byte count",
)
}
}
if manifest_name(file.path, false) {
payload_names.push(file.path)
}
if manifest_name(file.path, true) {
tag_names.push(file.path)
}
}
if !view.directories.contains("data") {
issue(
diagnostics, "missing_data", "data", "required payload directory is missing",
)
}
match (files.get("bagit.txt"), view.texts.get("bagit.txt")) {
(Some(_), Some(text)) =>
ignore(parse_declaration(text)) catch {
Invalid(message) =>
issue(diagnostics, "declaration", "bagit.txt", message)
}
_ =>
issue(
diagnostics, "missing_declaration", "bagit.txt", "declaration missing or not decoded",
)
}
if payload_names.is_empty() {
issue(
diagnostics, "missing_manifest", "", "at least one payload manifest is required",
)
}
payload_names.sort()
tag_names.sort()
let manifests : Array[Manifest] = []
let names = payload_names.copy()
names.append(tag_names)
for name in names {
match view.texts.get(name) {
Some(text) =>
manifests.push(parse_manifest(name, text)) catch {
Invalid(message) =>
issue(diagnostics, "manifest_format", name, message)
}
None => issue(diagnostics, "manifest_text", name, "manifest not decoded")
}
}
let coverage : Map[String, Map[String, Bool]] = Map([])
let mut first_tag : Manifest? = None
for manifest in manifests {
let listed : Map[String, Bool] = Map([])
for entry in manifest.entries {
listed[entry.path] = true
if !files.contains(entry.path) {
issue(
diagnostics,
"missing_file",
entry.path,
"referenced by " + manifest.name,
)
}
}
coverage[manifest.name] = listed
if manifest.tag {
match first_tag {
None => first_tag = Some(manifest)
Some(first) => {
let original = coverage.get(first.name).unwrap()
if original.length() != listed.length() ||
first.entries.any(e => !listed.contains(e.path)) {
issue(
diagnostics,
"different_tag_sets",
manifest.name,
"tag manifests SHOULD list the same tag files",
warning=true,
)
}
}
}
for name in payload_names {
if !listed.contains(name) {
issue(
diagnostics,
"tag_coverage",
manifest.name,
"must list payload manifest " + name,
)
}
}
for file in view.files {
if !file.path.has_prefix("data/") &&
!manifest_name(file.path, true) &&
!listed.contains(file.path) {
issue(
diagnostics,
"unprotected_tag",
file.path,
"not listed in " + manifest.name,
warning=true,
)
}
}
} else {
for file in payload {
if !listed.contains(file.path) {
issue(
diagnostics,
"payload_coverage",
manifest.name,
"must list payload file " + file.path,
)
}
}
}
}
let metadata = match
(files.get("bag-info.txt"), view.texts.get("bag-info.txt")) {
(Some(_), Some(text)) =>
parse_metadata(text) catch {
Invalid(message) => {
issue(diagnostics, "metadata_format", "bag-info.txt", message)
[]
}
}
(Some(_), None) => {
issue(
diagnostics, "metadata_text", "bag-info.txt", "metadata not decoded",
)
[]
}
_ => []
}
let oxums = metadata.filter(m => {
m.label[:].to_lower().to_owned() == "payload-oxum"
})
if oxums.length() > 1 {
issue(
diagnostics, "multiple_oxum", "bag-info.txt", "Payload-Oxum MUST NOT be repeated",
)
}
for oxum in oxums {
let parts = oxum.value.split(".").collect()
if parts.length() != 2 ||
parts.any(p => p == "" || !p.iter().all(c => c >= '0' && c <= '9')) {
issue(
diagnostics, "oxum_format", "bag-info.txt", "expected byte-count.file-count",
)
} else {
try {
let expected_bytes = @string.parse_int64(parts[0])
let expected_files = @string.parse_int64(parts[1])
if expected_bytes != bytes ||
expected_files != payload.length().to_int64() {
let actual = "\{bytes}.\{payload.length()}"
issue(
diagnostics,
"oxum_mismatch",
"bag-info.txt",
"declared " + oxum.value + ", observed " + actual,
)
}
} catch {
_ =>
issue(
diagnostics, "oxum_format", "bag-info.txt", "Payload-Oxum exceeds supported Int64 range",
)
}
}
}
match (files.get("fetch.txt"), view.texts.get("fetch.txt")) {
(Some(_), Some(text)) =>
try {
for entry in parse_fetch(text) {
for manifest in manifests {
if !manifest.tag &&
!coverage.get(manifest.name).unwrap().contains(entry.path) {
issue(
diagnostics,
"fetch_coverage",
"fetch.txt",
entry.path + " is absent from " + manifest.name,
)
}
}
match files.get(entry.path) {
None =>
issue(
diagnostics,
"fetch_pending",
entry.path,
"not downloaded; no network requests were made",
warning=true,
)
Some(file) =>
if entry.size is Some(expected) && file.size != expected {
issue(
diagnostics,
"fetch_size",
entry.path,
"downloaded size does not match fetch entry",
)
}
}
}
} catch {
Invalid(message) =>
issue(diagnostics, "fetch_format", "fetch.txt", message)
}
(Some(_), None) =>
issue(diagnostics, "fetch_text", "fetch.txt", "fetch file not decoded")
_ => ()
}
let complete = !diagnostics.any(d => d.severity == Error)
let mut verified = 0
for manifest in manifests {
for entry in manifest.entries {
if files.get(entry.path) is Some(file) {
match file.digests.get(manifest.algorithm.name()) {
Some(digest) if valid_checksum(digest, manifest.algorithm) =>
if digest[:].to_lower().to_owned() == entry.checksum {
verified = verified + 1
} else {
issue(
diagnostics,
"checksum_mismatch",
entry.path,
"checksum differs in " + manifest.name,
)
}
_ =>
issue(
diagnostics,
"digest_unavailable",
entry.path,
"host did not provide a valid raw-byte digest",
)
}
}
}
}
{
complete,
valid: complete && !diagnostics.any(d => d.severity == Error),
payload_files: payload.length(),
payload_bytes: bytes,
verified_checksums: verified,
diagnostics,
}
}