///|
pub(all) struct MetadataRule {
label : String
required : Bool
repeatable : Bool
values : Array[String]
} derive(@debug.Debug)
///|
pub(all) struct Profile {
identifier : String
version : String
metadata : Array[MetadataRule]
manifests_required : Array[String]
manifests_allowed : Array[String]?
tag_manifests_required : Array[String]
tag_manifests_allowed : Array[String]?
allow_fetch : Bool
require_fetch : Bool
data_empty : Bool
serialization : String
accepted_serializations : Array[String]
bag_versions : Array[String]
tags_required : Array[String]
tags_allowed : Array[String]
payload_required : Array[String]
payload_allowed : Array[String]
} derive(@debug.Debug)
///|
pub(all) struct ProfileReport {
conforms : Bool
bag : Report
diagnostics : Array[Diagnostic]
} derive(@debug.Debug, ToJson)
///|
fn profile_object(json : Json) -> Map[String, Json] raise FormatError {
match json {
Object(fields) => fields
_ => raise Invalid("profile field must be an object")
}
}
///|
fn profile_keys(
fields : Map[String, Json],
allowed : Array[String],
) -> Unit raise FormatError {
for key, _ in fields {
guard allowed.contains(key) else {
raise Invalid("unknown profile constraint: " + key)
}
}
}
///|
fn profile_string(
fields : Map[String, Json],
key : String,
) -> String raise FormatError {
match fields.get(key) {
Some(String(value)) if value != "" => value
_ => raise Invalid("missing or invalid profile string: " + key)
}
}
///|
fn profile_bool(
fields : Map[String, Json],
key : String,
fallback : Bool,
) -> Bool raise FormatError {
match fields.get(key) {
None => fallback
Some(True) => true
Some(False) => false
_ => raise Invalid("profile field must be boolean: " + key)
}
}
///|
fn profile_strings(
fields : Map[String, Json],
key : String,
fallback : Array[String],
) -> Array[String] raise FormatError {
match fields.get(key) {
None => fallback.copy()
Some(Array(values)) => {
let result : Array[String] = []
for value in values {
match value {
String(text) if text != "" => {
guard !result.contains(text) else {
raise Invalid("duplicate profile value: " + key)
}
result.push(text)
}
_ =>
raise Invalid("profile field must contain nonempty strings: " + key)
}
}
result
}
_ => raise Invalid("profile field must be an array: " + key)
}
}
///|
/// Only '*' is special. It matches zero or more code units, including '/'.
pub fn profile_path_matches(pattern : String, path : String) -> Bool {
let mut p = 0
let mut i = 0
let mut star = -1
let mut retry = 0
while i < path.length() {
if p < pattern.length() && pattern[p] == path[i] && pattern[p] != '*' {
p = p + 1
i = i + 1
} else if p < pattern.length() && pattern[p] == '*' {
star = p
p = p + 1
retry = i
} else if star >= 0 {
retry = retry + 1
i = retry
p = star + 1
} else {
return false
}
}
while p < pattern.length() && pattern[p] == '*' {
p = p + 1
}
p == pattern.length()
}
///|
fn profile_paths(
paths : Array[String],
payload : Bool,
patterns : Bool,
) -> Unit raise FormatError {
for path in paths {
guard patterns || !path.contains("*") else {
raise Invalid("required paths cannot contain wildcards")
}
let cleaned = path.replace(old="*", new="x")
let cleaned = if cleaned.has_suffix("/") { cleaned + "x" } else { cleaned }
check_path(cleaned)
guard !patterns || !path.has_suffix("/") else {
raise Invalid("allowed directories must end in /*")
}
guard payload == path.has_prefix("data/") || (patterns && path == "*") else {
raise Invalid(
"profile path is in the wrong payload/tag namespace: " + path,
)
}
guard payload || !path.has_suffix("/") else {
raise Invalid("required tag must be a file")
}
}
}
///|
fn profile_subset(
required : Array[String],
allowed : Array[String]?,
key : String,
) -> Unit raise FormatError {
if allowed is Some(names) {
for name in required {
guard names.contains(name) else {
raise Invalid(key + " excludes a required algorithm")
}
}
}
}
///|
/// Strict BagIt Profiles 1.4 JSON. Older versions and unknown constraints fail closed.
pub fn parse_profile(json : Json) -> Profile raise FormatError {
let fields = profile_object(json)
profile_keys(fields, [
"BagIt-Profile-Info", "Bag-Info", "Manifests-Required", "Manifests-Allowed",
"Tag-Manifests-Required", "Tag-Manifests-Allowed", "Allow-Fetch.txt", "Fetch.txt-Required",
"Data-Empty", "Serialization", "Accept-Serialization", "Accept-BagIt-Version",
"Tag-Files-Required", "Tag-Files-Allowed", "Payload-Files-Required", "Payload-Files-Allowed",
])
let info = profile_object(
fields.get("BagIt-Profile-Info").unwrap_or(Json::null()),
)
profile_keys(info, [
"Source-Organization", "External-Description", "Version", "BagIt-Profile-Identifier",
"BagIt-Profile-Version", "Contact-Name", "Contact-Email", "Contact-Phone",
])
let identifier = profile_string(info, "BagIt-Profile-Identifier")
let version = profile_string(info, "Version")
ignore(profile_string(info, "Source-Organization"))
ignore(profile_string(info, "External-Description"))
guard profile_string(info, "BagIt-Profile-Version") == "1.4.0" else {
raise Invalid("only BagIt Profiles 1.4.0 is supported")
}
for key in ["Contact-Name", "Contact-Email", "Contact-Phone"] {
if info.contains(key) {
ignore(profile_string(info, key))
}
}
let metadata : Array[MetadataRule] = []
if fields.get("Bag-Info") is Some(value) {
for label, rule in profile_object(value) {
ignore(render_metadata([{ label, value: "check", }]))
let rule = profile_object(rule)
profile_keys(rule, ["required", "repeatable", "values", "description"])
if rule.contains("description") {
ignore(profile_string(rule, "description"))
}
metadata.push({
label,
required: profile_bool(rule, "required", false),
repeatable: profile_bool(rule, "repeatable", true),
values: profile_strings(rule, "values", []),
})
}
}
let manifests_required = profile_strings(fields, "Manifests-Required", [])
let manifests_allowed = if fields.contains("Manifests-Allowed") {
Some(profile_strings(fields, "Manifests-Allowed", []))
} else {
None
}
let tag_manifests_required = profile_strings(
fields,
"Tag-Manifests-Required",
[],
)
let tag_manifests_allowed = if fields.contains("Tag-Manifests-Allowed") {
Some(profile_strings(fields, "Tag-Manifests-Allowed", []))
} else {
None
}
profile_subset(manifests_required, manifests_allowed, "Manifests-Allowed")
profile_subset(
tag_manifests_required, tag_manifests_allowed, "Tag-Manifests-Allowed",
)
let allow_fetch = profile_bool(fields, "Allow-Fetch.txt", true)
let require_fetch = profile_bool(fields, "Fetch.txt-Required", false)
guard allow_fetch || !require_fetch else {
raise Invalid("required fetch.txt cannot be forbidden")
}
let serialization = match fields.get("Serialization") {
None => "optional"
Some(String("forbidden" | "required" | "optional" as value)) => value
_ => raise Invalid("invalid Serialization constraint")
}
let accepted_serializations = profile_strings(
fields,
"Accept-Serialization",
[],
)
guard serialization == "forbidden" || !accepted_serializations.is_empty() else {
raise Invalid(
"Accept-Serialization is required for optional/required serialization",
)
}
let bag_versions = profile_strings(fields, "Accept-BagIt-Version", [])
guard !bag_versions.is_empty() else {
raise Invalid("Accept-BagIt-Version must not be empty")
}
let tags_required = profile_strings(fields, "Tag-Files-Required", [])
let tags_allowed = profile_strings(fields, "Tag-Files-Allowed", ["*"])
let payload_required = profile_strings(fields, "Payload-Files-Required", [])
let payload_allowed = profile_strings(fields, "Payload-Files-Allowed", ["*"])
profile_paths(tags_required, false, false)
profile_paths(tags_allowed, false, true)
profile_paths(payload_required, true, false)
profile_paths(payload_allowed, true, true)
for path in tags_required {
guard tags_allowed.any(p => profile_path_matches(p, path)) else {
raise Invalid("required tag is forbidden: " + path)
}
}
for path in payload_required {
let probe = if path.has_suffix("/") { path + "x" } else { path }
guard payload_allowed.any(p => profile_path_matches(p, probe)) else {
raise Invalid("required payload is forbidden: " + path)
}
}
{
identifier,
version,
metadata,
manifests_required,
manifests_allowed,
tag_manifests_required,
tag_manifests_allowed,
allow_fetch,
require_fetch,
data_empty: profile_bool(fields, "Data-Empty", false),
serialization,
accepted_serializations,
bag_versions,
tags_required,
tags_allowed,
payload_required,
payload_allowed,
}
}
///|
fn profile_algorithms(
diagnostics : Array[Diagnostic],
files : Array[FileRecord],
required : Array[String],
allowed : Array[String]?,
tag : Bool,
) -> Unit {
let prefix = if tag { "tagmanifest-" } else { "manifest-" }
for name in required {
let path = prefix + name + ".txt"
if !files.any(f => f.path == path) {
issue(
diagnostics, "profile_manifest_required", path, "required by profile",
)
}
}
if allowed is Some(names) {
for file in files {
if manifest_name(file.path, tag) &&
!names.any(n => file.path == prefix + n + ".txt") {
issue(
diagnostics,
"profile_manifest_forbidden",
file.path,
"algorithm is not allowed by profile",
)
}
}
}
}
///|
/// Validates a directory bag and receiving rules; no profile URL or payload is fetched.
pub fn validate_profile(view : BagView, profile : Profile) -> ProfileReport {
let bag = validate(view)
let diagnostics : Array[Diagnostic] = []
if profile.serialization == "required" {
issue(
diagnostics, "profile_serialization", "", "profile requires a serialized bag; input is a directory",
)
}
match view.texts.get("bagit.txt") {
Some(text) => {
let declaration = parse_declaration(text) catch {
Invalid(_) => { version: "", encoding: "", }
}
if !profile.bag_versions.contains(declaration.version) {
issue(
diagnostics, "profile_version", "bagit.txt", "version is not accepted by profile",
)
}
}
None =>
issue(
diagnostics, "profile_version", "bagit.txt", "declaration is unavailable",
)
}
let metadata = match view.texts.get("bag-info.txt") {
Some(text) => parse_metadata(text) catch { Invalid(_) => [] }
None => []
}
let identifiers = metadata.filter(m => {
m.label[:].to_lower().to_owned() == "bagit-profile-identifier"
})
if !identifiers.any(m => m.value == profile.identifier) {
issue(
diagnostics, "profile_identifier", "bag-info.txt", "matching BagIt-Profile-Identifier is required",
)
}
for rule in profile.metadata {
let label = rule.label[:].to_lower().to_owned()
let entries = metadata.filter(m => m.label[:].to_lower().to_owned() == label)
if rule.required && entries.is_empty() {
issue(
diagnostics,
"profile_metadata_required",
"bag-info.txt",
rule.label + " is required",
)
}
if !rule.repeatable && entries.length() > 1 {
issue(
diagnostics,
"profile_metadata_repeat",
"bag-info.txt",
rule.label + " must not repeat",
)
}
for entry in entries {
if !rule.values.is_empty() && !rule.values.contains(entry.value) {
issue(
diagnostics,
"profile_metadata_value",
"bag-info.txt",
rule.label + " has a disallowed value",
)
}
}
}
profile_algorithms(
diagnostics,
view.files,
profile.manifests_required,
profile.manifests_allowed,
false,
)
profile_algorithms(
diagnostics,
view.files,
profile.tag_manifests_required,
profile.tag_manifests_allowed,
true,
)
let fetch = view.files.any(f => f.path == "fetch.txt")
if !profile.allow_fetch && fetch {
issue(
diagnostics, "profile_fetch_forbidden", "fetch.txt", "fetch.txt is forbidden",
)
}
if profile.require_fetch && !fetch {
issue(
diagnostics, "profile_fetch_required", "fetch.txt", "fetch.txt is required",
)
}
let payload = view.files.filter(f => f.path.has_prefix("data/"))
if profile.data_empty &&
!(payload.is_empty() || (payload.length() == 1 && payload[0].size == 0L)) {
issue(
diagnostics, "profile_data_empty", "data", "profile requires zero files or one empty file",
)
}
for path in profile.tags_required {
if !view.files.any(f => f.path == path) {
issue(
diagnostics, "profile_tag_required", path, "required tag is missing",
)
}
}
for path in profile.payload_required {
let present = if path.has_suffix("/") {
payload.any(f => f.path.has_prefix(path)) ||
view.directories.any(d => d.has_prefix(path))
} else {
payload.any(f => f.path == path)
}
if !present {
issue(
diagnostics, "profile_payload_required", path, "required payload file or nonempty directory is missing",
)
}
}
for file in view.files {
let patterns = if file.path.has_prefix("data/") {
profile.payload_allowed
} else {
profile.tags_allowed
}
if !patterns.any(p => profile_path_matches(p, file.path)) {
issue(
diagnostics,
"profile_path_forbidden",
file.path,
"path is not allowed by profile",
)
}
}
for directory in view.directories {
if directory.has_prefix("data/") &&
!profile.payload_allowed.any(p => profile_path_matches(p, directory + "/")) {
issue(
diagnostics, "profile_directory_forbidden", directory, "payload directory is not allowed by profile",
)
}
}
{ conforms: bag.valid && diagnostics.is_empty(), bag, diagnostics, }
}