Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions crates/core/src/model/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,31 @@ pub struct ManifestMetadata {
#[cfg_attr(feature = "schema", schemars(required))]
pub description: Option<LocalizedText>,
/// Access rights authority IRI (`PUBLIC` / `RESTRICTED` / `NON_PUBLIC`).
#[cfg_attr(
feature = "schema",
schemars(schema_with = "crate::validate_pkg::access_rights_schema")
)]
pub access_rights: String,
/// Legislation mandating the dataset (EU ELI IRIs, >= 1).
#[cfg_attr(
feature = "schema",
schemars(
length(min = 1, max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub applicable_legislation: Vec<String>,
/// Reuse license for this dataset (IRI).
#[cfg_attr(
feature = "schema",
schemars(length(min = 1, max = crate::validate_pkg::MAX_IRI_LEN))
)]
pub license: String,
/// Creating agents (>= 1).
#[cfg_attr(
feature = "schema",
schemars(length(min = 1, max = crate::validate_pkg::MAX_CREATORS_COUNT))
)]
pub creator: Vec<Agent>,
/// GDI health category IRIs (>= 1).
#[cfg_attr(
Expand Down Expand Up @@ -173,9 +192,23 @@ pub struct ManifestMetadata {
pub type_: Option<Vec<String>>,
/// DPV legal basis IRIs (for real personal data).
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(
length(max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub legal_basis: Option<Vec<String>>,
/// Publication DOI IRIs.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(
length(max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub is_referenced_by: Option<Vec<String>>,
/// Secondary identifiers.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/model/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub enum LocalizedText {
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Agent {
/// `foaf:name`.
#[cfg_attr(
feature = "schema",
schemars(length(min = 1, max = crate::validate_pkg::MAX_CREATOR_NAME_LEN))
)]
pub name: String,
}

Expand Down
35 changes: 34 additions & 1 deletion crates/core/src/model/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,35 @@ pub struct MetadataOverlay {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<LocalizedText>,
/// Access rights authority IRI (`PUBLIC` / `RESTRICTED` / `NON_PUBLIC`).
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(schema_with = "crate::validate_pkg::optional_access_rights_schema")
)]
pub access_rights: Option<String>,
/// Legislation mandating the dataset (EU ELI IRIs, >= 1).
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(
length(min = 1, max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub applicable_legislation: Option<Vec<String>>,
/// Reuse license for this dataset (IRI).
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(length(min = 1, max = crate::validate_pkg::MAX_IRI_LEN))
)]
pub license: Option<String>,
/// Creating agents (>= 1).
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(length(min = 1, max = crate::validate_pkg::MAX_CREATORS_COUNT))
)]
pub creator: Option<Vec<Agent>>,
/// GDI health category IRIs (>= 1).
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -64,9 +83,23 @@ pub struct MetadataOverlay {
pub type_: Option<Vec<String>>,
/// DPV legal basis IRIs (for real personal data).
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(
length(max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub legal_basis: Option<Vec<String>>,
/// Publication DOI IRIs.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(
length(max = crate::validate_pkg::MAX_IRI_LIST_COUNT),
inner(length(max = crate::validate_pkg::MAX_IRI_LEN))
)
)]
pub is_referenced_by: Option<Vec<String>>,
/// Secondary identifiers.
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
20 changes: 17 additions & 3 deletions crates/core/src/validate_pkg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ pub const MAX_KEYWORDS_COUNT: usize = 50;
/// `creator name` cap.
pub const MAX_CREATOR_NAME_LEN: usize = 255;
/// `creator` count cap.
const MAX_CREATORS_COUNT: usize = 64;
pub const MAX_CREATORS_COUNT: usize = 64;
/// `contactPoint fn` cap.
pub const MAX_CONTACT_FN_LEN: usize = 255;
/// `email` cap (RFC 5321).
Expand All @@ -96,7 +96,7 @@ pub const MAILTO_EMAIL_PATTERN: &str =
/// URL cap (`hasURL`, `afSourceReference`, ...).
const MAX_URL_LEN: usize = 2048;
/// IRI cap (every IRI metadata field).
const MAX_IRI_LEN: usize = 2048;
pub const MAX_IRI_LEN: usize = 2048;
/// `otherIdentifier.notation` cap.
const MAX_NOTATION_LEN: usize = 255;
/// `otherIdentifier.schemaAgency` cap.
Expand All @@ -113,7 +113,7 @@ const MAX_OTHER_IDS_COUNT: usize = 50;
/// oversized manifest is re-serialised on every request. 64 is far above any real value (a
/// dataset cites a handful of EU legislation IRIs) and far below the size at which
/// re-serialising the list becomes one.
const MAX_IRI_LIST_COUNT: usize = 64;
pub const MAX_IRI_LIST_COUNT: usize = 64;
/// `file category` / `reference` / `preciseReference` cap.
const MAX_FILE_CATEGORY_LEN: usize = 32;
/// File groups count cap.
Expand Down Expand Up @@ -302,6 +302,20 @@ pub(crate) fn dataset_type_schema(_: &mut schemars::SchemaGenerator) -> schemars
closed_list_schema(DATASET_TYPES, 1, true)
}

/// `accessRights` in the manifest schema: one [`ACCESS_RIGHTS`] IRI.
#[cfg(feature = "schema")]
pub(crate) fn access_rights_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({"type": "string", "enum": ACCESS_RIGHTS})
}

/// `accessRights` in the overlay schema, where it may be left out.
#[cfg(feature = "schema")]
pub(crate) fn optional_access_rights_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mut allowed: Vec<serde_json::Value> = ACCESS_RIGHTS.iter().map(|&iri| iri.into()).collect();
allowed.push(serde_json::Value::Null);
schemars::json_schema!({"type": ["string", "null"], "enum": allowed})
}

/// `conformsTo` IRIs: the closed set gdi-metadata's `DatasetShape` enumerates
/// (`sh:in`, 3 values; vendored `Dataset.ttl`).
///
Expand Down
2 changes: 1 addition & 1 deletion crates/gdi-dataset-tool/src/commands/cmd_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ fn preflight_vcf_headers(sources: &[PathBuf]) -> Result<(), ToolError> {
let mut af_populations: BTreeSet<String> = BTreeSet::new();
for source in sources {
let pops = read_header_populations(source)
.map_err(|e| with_source(source, ToolError::from(&e)))?;
.map_err(|e| with_source(source, ToolError::from_vcf_stage(&e)))?;
af_populations.extend(pops);
if af_populations.len() > MAX_POPULATIONS {
return Err(ToolError::user(format!(
Expand Down
18 changes: 15 additions & 3 deletions crates/gdi-dataset-tool/src/commands/cmd_doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! It validates the active profile loads, the **provider identity** loads (without
//! generating it) and its recipient derives, and the resolved **node recipient**
//! (an explicit `--recipient` overrides in either mode; otherwise
//! `node_recipient_url` online or `node_recipient_file` offline) is a usable
//! `node_recipient_url` online, or offline the local copy `pack` would use) is a usable
//! crypt4gh recipient. **Online** it additionally checks the FDP root
//! responds, the catalog list is fetchable, and the S3 bucket is writable (a probe
//! PUT/DELETE on a reserved key). It does not probe `/health/ready`, which is on the
Expand Down Expand Up @@ -144,7 +144,7 @@ pub fn run(
} else if online {
"node_recipient_url"
} else {
"node_recipient_file"
"the local recipient file"
};
crate::output::note(&format!("checking node recipient via {recipient_src}"));
checks.push(check_node_recipient(&active, args, online, config_path));
Expand Down Expand Up @@ -323,8 +323,20 @@ fn check_node_recipient(
"no node_recipient_url and no service_url".to_owned(),
)),
}
} else if recipient::node_recipient_url(active).is_some() {
// Offline with a node URL: the local pin `pack` falls back to when the fetch fails.
let configured = active.node_recipient_path(config_path);
let default_pin = recipient::recipient_pin_path_for_read(active, config_path);
match recipient::offline_pin_fallback(configured.as_deref(), default_pin.as_deref()) {
Some(found) => found.map(|(pk, _)| pk),
None => Err(ToolError::user(
"no local node recipient: set node_recipient_file, or pin the node's key \
with `keys pin-recipient`"
.to_owned(),
)),
}
} else {
// Offline: the local recipient file is required.
// No node URL: the local recipient file is required, as it is for `pack`.
match active.node_recipient_path(config_path) {
Some(file) => recipient::read_node_recipient_file(&file),
None => Err(ToolError::user(
Expand Down
44 changes: 19 additions & 25 deletions crates/gdi-dataset-tool/src/commands/cmd_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
//!
//! The generated template has the four sections (`metadata`, `files`, `internal`,
//! `config`). Required fields are `REPLACE:`-prefixed placeholders the provider must fill
//! in **except** `applicableLegislation`, which is pre-filled with the EHDS ELI (the one
//! required field with a sensible default — removable, at the cost of a `build` warning
//! that `--strict` fails on, and extendable with the GDPR or a national act), and
//! `license`, which is a `REPLACE:` placeholder so the provider chooses explicit terms.
//! in **except** `applicableLegislation`, pre-filled with the EHDS ELI (removable, at the
//! cost of a `build` warning that `--strict` fails on, and extendable with the GDPR or a
//! national act), and `catalog` when the active profile allows a single one. `license`
//! stays a placeholder so the provider chooses explicit terms.
//! Recommended fields (`keywords`, `numberOfUniqueIndividuals`) sit under their own
//! header; optional fields are shown commented out with example values.
//!
Expand Down Expand Up @@ -64,13 +64,10 @@ fn conforms_to_comment() -> String {
/// The scaffolded `package.yaml` template.
///
/// Required fields are `REPLACE:` placeholders except `applicableLegislation`
/// (the EHDS ELI) and `license` (a `REPLACE:` placeholder). Recommended fields
/// (the EHDS ELI) and, with a single allowed catalog, `catalog`. Recommended fields
/// are under their own header; optional fields are commented with examples.
///
/// `catalog_line` is the rendered `metadata.catalog` line (with any trailing
/// allow-list comment): either the static `REPLACE:` placeholder or, when the
/// active profile has a `catalogs` allow-list, the available catalog name(s)
/// (see [`scaffold_catalog_line`]).
/// `catalog_line` is the rendered `metadata.catalog` line (see [`scaffold_catalog_line`]).
#[expect(
clippy::too_many_lines,
reason = "all but a handful of these lines are ONE string literal — the scaffold document \
Expand Down Expand Up @@ -262,20 +259,16 @@ pub fn run(
Ok(())
}

/// Render the `metadata.catalog` line from the profile's allow-list names: the
/// first name becomes the value, any others are listed in a trailing comment. An
/// empty list yields the static `REPLACE:` placeholder.
/// Render the `metadata.catalog` line from the profile's allow-list names: one name is
/// filled in, several go into the `REPLACE:` placeholder for the provider to pick from
/// (a pre-filled guess would pass `build` unnoticed), and none gives the static
/// placeholder.
fn scaffold_catalog_line<'a>(names: impl Iterator<Item = &'a str>) -> String {
let names: Vec<&str> = names.collect();
match names.split_first() {
None => CATALOG_PLACEHOLDER.to_owned(),
Some((first, [])) => format!(r#"catalog: "{first}""#),
Some((first, rest)) => {
format!(
r#"catalog: "{first}" # other allowed catalogs: {}"#,
rest.join(", ")
)
}
match names.as_slice() {
[] => CATALOG_PLACEHOLDER.to_owned(),
[only] => format!(r#"catalog: "{only}""#),
several => format!(r#"catalog: "REPLACE: one of {}""#, several.join(", ")),
}
}

Expand Down Expand Up @@ -368,9 +361,10 @@ mod tests {
scaffold_catalog_line(["gdi-aggregated"].into_iter()),
r#"catalog: "gdi-aggregated""#
);
// Several names -> first is the value, the rest are listed in a comment.
let line = scaffold_catalog_line(["a", "b", "c"].into_iter());
assert!(line.starts_with(r#"catalog: "a""#), "got: {line}");
assert!(line.contains("other allowed catalogs: b, c"), "got: {line}");
// Several names -> a placeholder listing them; the provider picks.
assert_eq!(
scaffold_catalog_line(["a", "b", "c"].into_iter()),
r#"catalog: "REPLACE: one of a, b, c""#
);
}
}
8 changes: 4 additions & 4 deletions crates/gdi-dataset-tool/src/commands/cmd_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,10 @@ fn recipient_oneline(pk: &PublicKey) -> String {
.join("")
}

/// Resolve the active profile's node recipient the way `pack`/`doctor` do:
/// `node_recipient_url` (online primary, defaulting to
/// `{service_url}/.well-known/c4gh-recipient`) if configured, else the offline
/// `node_recipient_file`. Returns an error (rendered by [`show`] as an
/// Resolve the active profile's node recipient: `node_recipient_url` (online primary,
/// defaulting to `{service_url}/.well-known/c4gh-recipient`) if configured, else the
/// offline `node_recipient_file`. Unlike `pack`, a failed fetch does not fall back to the
/// local pin. Returns an error (rendered by [`show`] as an
/// unconditional `node recipient: <unavailable: …>` stdout line) when neither is
/// configured or the online fetch fails.
fn resolve_node_recipient(
Expand Down
7 changes: 3 additions & 4 deletions crates/gdi-dataset-tool/src/commands/cmd_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,11 @@ pub fn resolve_recipients_readonly(
Ok(vec![node, provider])
}

/// Resolve the node recipient with the spec precedence (mirrored from
/// Resolve the node recipient with the spec precedence (mirrored by
/// `cmd_doctor::check_node_recipient`): explicit `--recipient <file>`
/// wins; else the active profile's `node_recipient_url` (online primary, defaulting
/// to `{service_url}/.well-known/c4gh-recipient`) is fetched; else the offline
/// `node_recipient_file`. An online-fetch failure when a URL is configured is a hard error
/// — never a silent fall-through to a possibly-stale local file.
/// to `{service_url}/.well-known/c4gh-recipient`) is fetched, and when the fetch fails
/// the local pin is used with a warning; else the offline `node_recipient_file`.
///
/// `--recipient` short-circuits before the profile is loaded, so it works with no
/// profile configured.
Expand Down
7 changes: 4 additions & 3 deletions crates/gdi-dataset-tool/tests/it/build_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,8 @@ fn build_args_for(pkg: &Path, out: &Path) -> BuildArgs {
}
}

/// The header preflight rejects a VCF with no `AF` INFO field before conversion, with
/// the source path prefixed onto the error.
/// The header preflight rejects a VCF with no `AF` INFO field before conversion, as an
/// `invalid VCF` error prefixed with the source path.
#[test]
#[serial(env)]
fn build_missing_af_field_is_rejected() {
Expand All @@ -694,7 +694,8 @@ fn build_missing_af_field_is_rejected() {
"no AF field",
);
assert!(
err.message.contains("No AF INFO fields found") && err.message.contains("noaf.vcf"),
err.message.contains("invalid VCF: No AF INFO fields found")
&& err.message.contains("noaf.vcf"),
"expected a path-prefixed no-AF error, got: {}",
err.message
);
Expand Down
40 changes: 40 additions & 0 deletions crates/gdi-dataset-tool/tests/it/doctor_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,46 @@ fn offline_broken_recipient_fails() {
);
}

/// Offline, `doctor` finds the pin `keys pin-recipient` writes when the profile names no
/// `node_recipient_file`, as `pack` does when the fetch fails.
#[test]
#[serial(env)]
fn offline_finds_the_url_keyed_pin() {
let tmp = tempfile::tempdir().unwrap();
let _config_dir = make_provider_identity(tmp.path());
let recipient = make_node_recipient(tmp.path());
let cfg = tmp.path().join("tool.toml");
fs::write(
&cfg,
"[profiles.default]\nservice_url = \"http://127.0.0.1:1\"\n",
)
.unwrap();
let cli = gdi_dataset_tool::cli::Cli::try_parse_from([
"gdi-dataset-tool",
"--config",
cfg.to_str().unwrap(),
"keys",
"pin-recipient",
"--file",
recipient.to_str().unwrap(),
])
.unwrap();
gdi_dataset_tool::run(cli).expect("keys pin-recipient --file");
let pins = fs::read_dir(tmp.path().join("recipients")).unwrap().count();
assert_eq!(
pins, 1,
"the pin must exist for the check below to mean anything"
);

let args = DoctorArgs {
offline: true,
recipient: None,
format: gdi_dataset_tool::cli::OutputFormat::Text,
};
gdi_dataset_tool::commands::cmd_doctor::run(&args, None, Some(&cfg))
.expect("doctor --offline finds the pinned recipient");
}

#[test]
#[serial(env)]
fn online_unreachable_node_fails() {
Expand Down
Loading