fix: pin plugin resolution metadata

This commit is contained in:
Keisuke Hirata 2026-06-15 23:52:13 +09:00
parent a03a9da64a
commit ede7acfdf6
No known key found for this signature in database
5 changed files with 265 additions and 25 deletions

View File

@ -470,7 +470,12 @@ impl SkillsConfig {
} }
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig { fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
let upper_has_resolved_plan = upper.has_resolved_plan();
base.enabled.extend(upper.enabled); base.enabled.extend(upper.enabled);
if upper_has_resolved_plan {
base.resolved = upper.resolved;
base.diagnostics = upper.diagnostics;
}
base base
} }

View File

@ -878,6 +878,7 @@ model_id = "claude-sonnet-4-20250514"
"{MINIMAL_REQUIRED}\n\ "{MINIMAL_REQUIRED}\n\
[[plugins.enabled]]\n\ [[plugins.enabled]]\n\
id = \"project:example\"\n\ id = \"project:example\"\n\
version = \"0.1.0\"\n\
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\ digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
surfaces = [\"hook\"]\n\n\ surfaces = [\"hook\"]\n\n\
[plugins.enabled.config]\n\ [plugins.enabled.config]\n\
@ -887,6 +888,10 @@ model_id = "claude-sonnet-4-20250514"
assert_eq!(manifest.plugins.enabled.len(), 1); assert_eq!(manifest.plugins.enabled.len(), 1);
let enabled = &manifest.plugins.enabled[0]; let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example"); assert_eq!(enabled.id, "project:example");
assert_eq!(
enabled.version.as_ref().map(|version| version.0.as_str()),
Some("0.1.0")
);
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]); assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
assert_eq!( assert_eq!(
enabled enabled

View File

@ -19,11 +19,20 @@ const ZIP_UNIX_FILE_TYPE_MASK: u32 = 0o170000;
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PluginConfig { pub struct PluginConfig {
pub enabled: Vec<PluginEnablementConfig>, pub enabled: Vec<PluginEnablementConfig>,
/// Runtime restore metadata. Fresh resolution fills this from discovered packages;
/// restore uses it without selecting newer mutable-store contents.
pub resolved: Vec<ResolvedPluginRecord>,
/// Safe bounded discovery/resolution diagnostics recorded with the resolved plan.
pub diagnostics: Vec<PluginDiagnostic>,
} }
impl PluginConfig { impl PluginConfig {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.enabled.is_empty() self.enabled.is_empty() && self.resolved.is_empty()
}
pub fn has_resolved_plan(&self) -> bool {
!self.resolved.is_empty() || !self.diagnostics.is_empty()
} }
} }
@ -32,6 +41,8 @@ impl PluginConfig {
pub struct PluginEnablementConfig { pub struct PluginEnablementConfig {
/// Source-qualified plugin id such as `user:example`, `project:example`, or `builtin:example`. /// Source-qualified plugin id such as `user:example`, `project:example`, or `builtin:example`.
pub id: String, pub id: String,
/// Optional exact package version requirement. Rich version constraints are deferred.
pub version: Option<PluginExactVersion>,
/// Optional deterministic digest pin in `sha256:<hex>` form. /// Optional deterministic digest pin in `sha256:<hex>` form.
pub digest: Option<String>, pub digest: Option<String>,
/// Optional explicit surface subset. When omitted, all declared package surfaces are selected. /// Optional explicit surface subset. When omitted, all declared package surfaces are selected.
@ -42,6 +53,16 @@ pub struct PluginEnablementConfig {
pub config: Option<toml::Value>, pub config: Option<toml::Value>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PluginExactVersion(pub String);
impl PluginExactVersion {
pub fn matches(&self, version: &str) -> bool {
self.0 == version
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct PluginGrantConfig { pub struct PluginGrantConfig {
@ -156,7 +177,7 @@ pub enum PluginIdParseError {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PluginPackageManifest { pub struct PluginPackageManifest {
pub api_version: u32, pub schema_version: u32,
pub id: String, pub id: String,
pub name: String, pub name: String,
pub version: String, pub version: String,
@ -277,6 +298,7 @@ pub struct ResolvedPlugin {
pub identity: SourceQualifiedPluginId, pub identity: SourceQualifiedPluginId,
pub source: PluginSourceKind, pub source: PluginSourceKind,
pub package_path: PathBuf, pub package_path: PathBuf,
pub package_label: String,
pub digest: String, pub digest: String,
pub manifest: PluginPackageManifest, pub manifest: PluginPackageManifest,
pub enabled_surfaces: Vec<PluginSurface>, pub enabled_surfaces: Vec<PluginSurface>,
@ -284,13 +306,44 @@ pub struct ResolvedPlugin {
pub config: Option<toml::Value>, pub config: Option<toml::Value>,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResolvedPluginRecord {
pub identity: SourceQualifiedPluginId,
pub source: PluginSourceKind,
pub package_path: PathBuf,
pub package_label: String,
pub digest: String,
pub version: String,
pub manifest: PluginPackageManifest,
pub enabled_surfaces: Vec<PluginSurface>,
pub grants: PluginGrantConfig,
pub config: Option<toml::Value>,
}
impl ResolvedPluginRecord {
fn from_resolved(resolved: &ResolvedPlugin) -> Self {
Self {
identity: resolved.identity.clone(),
source: resolved.source,
package_path: resolved.package_path.clone(),
package_label: resolved.package_label.clone(),
digest: resolved.digest.clone(),
version: resolved.manifest.version.clone(),
manifest: resolved.manifest.clone(),
enabled_surfaces: resolved.enabled_surfaces.clone(),
grants: resolved.grants.clone(),
config: resolved.config.clone(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct PluginResolution { pub struct PluginResolution {
pub resolved: Vec<ResolvedPlugin>, pub resolved: Vec<ResolvedPlugin>,
pub diagnostics: Vec<PluginDiagnostic>, pub diagnostics: Vec<PluginDiagnostic>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginDiagnostic { pub struct PluginDiagnostic {
pub kind: PluginDiagnosticKind, pub kind: PluginDiagnosticKind,
pub phase: PluginDiagnosticPhase, pub phase: PluginDiagnosticPhase,
@ -339,7 +392,8 @@ impl PluginDiagnostic {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticKind { pub enum PluginDiagnosticKind {
Missing, Missing,
Duplicate, Duplicate,
@ -355,7 +409,8 @@ pub enum PluginDiagnosticKind {
Io, Io,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginDiagnosticPhase { pub enum PluginDiagnosticPhase {
Discovery, Discovery,
Manifest, Manifest,
@ -476,6 +531,23 @@ pub fn resolve_enabled_plugins(
} }
} }
if let Some(required_version) = &enablement.version {
if !required_version.matches(&package.manifest.version) {
resolution.diagnostics.push(
PluginDiagnostic::new(
PluginDiagnosticKind::Version,
PluginDiagnosticPhase::Resolution,
"enabled plugin exact version requirement does not match discovered package version",
)
.with_source(identity.source)
.with_identity(&identity)
.with_package(&package.package_label)
.with_digest(&package.digest),
);
continue;
}
}
if !enablement.grants.is_empty() { if !enablement.grants.is_empty() {
resolution.diagnostics.push( resolution.diagnostics.push(
PluginDiagnostic::new( PluginDiagnostic::new(
@ -519,6 +591,7 @@ pub fn resolve_enabled_plugins(
identity: identity.clone(), identity: identity.clone(),
source: identity.source, source: identity.source,
package_path: package.package_path.clone(), package_path: package.package_path.clone(),
package_label: package.package_label.clone(),
digest: package.digest.clone(), digest: package.digest.clone(),
manifest: package.manifest.clone(), manifest: package.manifest.clone(),
enabled_surfaces: selected_surfaces.into_iter().collect(), enabled_surfaces: selected_surfaces.into_iter().collect(),
@ -530,6 +603,27 @@ pub fn resolve_enabled_plugins(
resolution resolution
} }
pub fn resolve_plugin_config_for_startup(
config: &PluginConfig,
options: &PluginDiscoveryOptions,
) -> PluginConfig {
if config.enabled.is_empty() || config.has_resolved_plan() {
return config.clone();
}
let discovery = discover_plugins(options);
let resolution = resolve_enabled_plugins(config, &discovery);
let mut snapshot = config.clone();
snapshot.resolved = resolution
.resolved
.iter()
.map(ResolvedPluginRecord::from_resolved)
.collect();
snapshot.diagnostics = discovery.diagnostics;
snapshot.diagnostics.extend(resolution.diagnostics);
snapshot
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct PluginStore { struct PluginStore {
source: PluginSourceKind, source: PluginSourceKind,
@ -756,10 +850,7 @@ fn read_package(
PluginDiagnostic::new( PluginDiagnostic::new(
PluginDiagnosticKind::Malformed, PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Manifest, PluginDiagnosticPhase::Manifest,
format!( safe_toml_parse_message(&error),
"plugin.toml could not be parsed: {}",
bounded_message(error.to_string())
),
) )
.with_source(source) .with_source(source)
.with_package(label) .with_package(label)
@ -784,11 +875,11 @@ fn validate_manifest(
label: &str, label: &str,
source: PluginSourceKind, source: PluginSourceKind,
) -> Result<(), PluginDiagnostic> { ) -> Result<(), PluginDiagnostic> {
if manifest.api_version != SUPPORTED_PLUGIN_API_VERSION { if manifest.schema_version != SUPPORTED_PLUGIN_API_VERSION {
return Err(PluginDiagnostic::new( return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Version, PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest, PluginDiagnosticPhase::Manifest,
"plugin API version is unsupported", "plugin schema/API version is unsupported",
) )
.with_source(source) .with_source(source)
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone())) .with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
@ -1264,13 +1355,26 @@ fn safe_io_error(error: &io::Error) -> &'static str {
} }
} }
fn safe_toml_parse_message(error: &toml::de::Error) -> String {
let mut message = String::from("plugin.toml could not be parsed");
if let Some(span) = error.span() {
message.push_str(&format!(" near byte span {}..{}", span.start, span.end));
}
bounded_message(message)
}
fn bounded_message(message: String) -> String { fn bounded_message(message: String) -> String {
const MAX: usize = 240; const MAX: usize = 240;
if message.len() <= MAX { if message.len() <= MAX {
message return message;
} else {
format!("{}", &message[..MAX])
} }
let end = message
.char_indices()
.map(|(index, _)| index)
.take_while(|index| *index <= MAX)
.last()
.unwrap_or(0);
format!("{}", &message[..end])
} }
fn is_safe_id(value: &str) -> bool { fn is_safe_id(value: &str) -> bool {
@ -1398,6 +1502,7 @@ mod tests {
..PluginEnablementConfig::default() ..PluginEnablementConfig::default()
}, },
], ],
..PluginConfig::default()
}, },
&report, &report,
); );
@ -1427,6 +1532,7 @@ mod tests {
digest: Some("sha256:0000".to_string()), digest: Some("sha256:0000".to_string()),
..PluginEnablementConfig::default() ..PluginEnablementConfig::default()
}], }],
..PluginConfig::default()
}, },
&report, &report,
); );
@ -1435,6 +1541,106 @@ mod tests {
assert_eq!(resolution.diagnostics[0].kind, PluginDiagnosticKind::Digest); assert_eq!(resolution.diagnostics[0].kind, PluginDiagnosticKind::Digest);
} }
#[test]
fn exact_version_mismatch_fails_closed_with_distinct_diagnostic() {
let (report, _) = fixture_with_enabled_plugin(false);
let resolution = resolve_enabled_plugins(
&PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".to_string(),
version: Some(PluginExactVersion("9.9.9".to_string())),
..PluginEnablementConfig::default()
}],
..PluginConfig::default()
},
&report,
);
assert!(resolution.resolved.is_empty());
assert_eq!(
resolution.diagnostics[0].kind,
PluginDiagnosticKind::Version
);
assert_eq!(
resolution.diagnostics[0].phase,
PluginDiagnosticPhase::Resolution
);
assert!(
!resolution
.diagnostics
.iter()
.any(|diag| diag.kind == PluginDiagnosticKind::Api)
);
}
#[test]
fn resolved_plan_pins_unpinned_enablement_for_restore() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
let plugins = workspace.join(".yoi/plugins");
fs::create_dir_all(&plugins).unwrap();
let package = plugins.join("example.yoi-plugin");
write_plugin_version(
&package,
"example",
"0.1.0",
&[PluginSurface::Hook],
&[("hooks/example.md", b"v1")],
);
let options = PluginDiscoveryOptions::new(&workspace);
let config = PluginConfig {
enabled: vec![PluginEnablementConfig {
id: "project:example".to_string(),
..PluginEnablementConfig::default()
}],
..PluginConfig::default()
};
let startup_snapshot = resolve_plugin_config_for_startup(&config, &options);
assert_eq!(startup_snapshot.resolved.len(), 1);
let restored_digest = startup_snapshot.resolved[0].digest.clone();
assert_eq!(startup_snapshot.resolved[0].version, "0.1.0");
write_plugin_version(
&package,
"example",
"0.2.0",
&[PluginSurface::Hook],
&[("hooks/example.md", b"v2")],
);
let fresh_snapshot = resolve_plugin_config_for_startup(&config, &options);
assert_ne!(fresh_snapshot.resolved[0].digest, restored_digest);
assert_eq!(fresh_snapshot.resolved[0].version, "0.2.0");
let restored_snapshot = resolve_plugin_config_for_startup(&startup_snapshot, &options);
assert_eq!(restored_snapshot.resolved[0].digest, restored_digest);
assert_eq!(restored_snapshot.resolved[0].version, "0.1.0");
}
#[test]
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().join("workspace");
let plugins = workspace.join(".yoi/plugins");
fs::create_dir_all(&plugins).unwrap();
let malformed = format!("schema_version = [\n# {}", "機密".repeat(200));
write_stored_zip(
&plugins.join("bad-multibyte.yoi-plugin"),
&[("plugin.toml", malformed.into_bytes(), 0)],
);
let report = discover_plugins(&PluginDiscoveryOptions::new(&workspace));
assert!(report.packages.is_empty());
let diagnostic = report
.diagnostics
.iter()
.find(|diag| diag.kind == PluginDiagnosticKind::Malformed)
.unwrap();
assert!(diagnostic.message.len() <= 241);
assert!(!diagnostic.message.contains("機密"));
}
#[test] #[test]
fn traversal_root_escape_in_archive_fails_closed() { fn traversal_root_escape_in_archive_fails_closed() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -1446,7 +1652,7 @@ mod tests {
&[ &[
( (
"plugin.toml", "plugin.toml",
manifest("escape", &[PluginSurface::Hook]).into_bytes(), manifest("escape", "0.1.0", &[PluginSurface::Hook]).into_bytes(),
0, 0,
), ),
("../evil", b"x".to_vec(), 0), ("../evil", b"x".to_vec(), 0),
@ -1490,10 +1696,10 @@ mod tests {
let plugins = workspace.join(".yoi/plugins"); let plugins = workspace.join(".yoi/plugins");
fs::create_dir_all(&plugins).unwrap(); fs::create_dir_all(&plugins).unwrap();
write_stored_zip( write_stored_zip(
&plugins.join("bad-api.yoi-plugin"), &plugins.join("bad-schema.yoi-plugin"),
&[( &[(
"plugin.toml", "plugin.toml",
manifest_with_api("bad_api", 999).into_bytes(), manifest_with_schema("bad_schema", "0.1.0", 999).into_bytes(),
0, 0,
)], )],
); );
@ -1509,7 +1715,7 @@ mod tests {
report report
.diagnostics .diagnostics
.iter() .iter()
.any(|diag| diag.kind == PluginDiagnosticKind::Version) .any(|diag| diag.kind == PluginDiagnosticKind::Api)
); );
assert!( assert!(
report report
@ -1539,6 +1745,7 @@ mod tests {
..PluginEnablementConfig::default() ..PluginEnablementConfig::default()
}, },
], ],
..PluginConfig::default()
}, },
&report, &report,
); );
@ -1579,6 +1786,7 @@ mod tests {
} else { } else {
vec![] vec![]
}, },
..PluginConfig::default()
}; };
(report, config) (report, config)
} }
@ -1589,7 +1797,21 @@ mod tests {
surfaces: &[PluginSurface], surfaces: &[PluginSurface],
extra_files: &[(&str, &[u8])], extra_files: &[(&str, &[u8])],
) { ) {
let mut entries = vec![("plugin.toml", manifest(id, surfaces).into_bytes(), 0)]; write_plugin_version(path, id, "0.1.0", surfaces, extra_files);
}
fn write_plugin_version(
path: &Path,
id: &str,
version: &str,
surfaces: &[PluginSurface],
extra_files: &[(&str, &[u8])],
) {
let mut entries = vec![(
"plugin.toml",
manifest(id, version, surfaces).into_bytes(),
0,
)];
if surfaces.contains(&PluginSurface::Hook) if surfaces.contains(&PluginSurface::Hook)
&& !extra_files && !extra_files
.iter() .iter()
@ -1605,17 +1827,17 @@ mod tests {
write_stored_zip(path, &entries); write_stored_zip(path, &entries);
} }
fn manifest(id: &str, surfaces: &[PluginSurface]) -> String { fn manifest(id: &str, version: &str, surfaces: &[PluginSurface]) -> String {
let mut manifest = manifest_with_api(id, SUPPORTED_PLUGIN_API_VERSION); let mut manifest = manifest_with_schema(id, version, SUPPORTED_PLUGIN_API_VERSION);
if surfaces.contains(&PluginSurface::Hook) { if surfaces.contains(&PluginSurface::Hook) {
manifest.push_str("\n[[hooks]]\nid = \"startup\"\nfile = \"hooks/example.md\"\n"); manifest.push_str("\n[[hooks]]\nid = \"startup\"\nfile = \"hooks/example.md\"\n");
} }
manifest manifest
} }
fn manifest_with_api(id: &str, api_version: u32) -> String { fn manifest_with_schema(id: &str, version: &str, schema_version: u32) -> String {
format!( format!(
"api_version = {api_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"0.1.0\"\n" "schema_version = {schema_version}\nid = \"{id}\"\nname = \"Example\"\nversion = \"{version}\"\n"
) )
} }

View File

@ -7,6 +7,7 @@ use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{ use manifest::{
Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver, Permission, PodManifest, PodManifestConfig, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ScopeConfig, ScopeRule, paths, ProfileSelector, ScopeConfig, ScopeRule, paths,
plugin::{PluginDiscoveryOptions, resolve_plugin_config_for_startup},
}; };
use pod_store::{CombinedStore, FsPodStore, PodMetadataStore}; use pod_store::{CombinedStore, FsPodStore, PodMetadataStore};
use session_store::{FsStore, SegmentId, Store}; use session_store::{FsStore, SegmentId, Store};
@ -184,9 +185,15 @@ where
apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?; apply_profile_launch_policy(&mut manifest, &workspace_root, cli.ticket_role.as_deref())?;
} }
apply_session_restore_overrides(&mut manifest, cli)?; apply_session_restore_overrides(&mut manifest, cli)?;
apply_plugin_resolution_plan(&mut manifest, &workspace_root);
Ok((manifest, loader)) Ok((manifest, loader))
} }
fn apply_plugin_resolution_plan(manifest: &mut PodManifest, workspace_root: &Path) {
let options = PluginDiscoveryOptions::new(workspace_root);
manifest.plugins = resolve_plugin_config_for_startup(&manifest.plugins, &options);
}
fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> { fn apply_session_restore_overrides(manifest: &mut PodManifest, cli: &Cli) -> Result<(), String> {
if let Some(pod_name) = cli.pod.as_deref() { if let Some(pod_name) = cli.pod.as_deref() {
manifest.pod.name = pod_name.to_string(); manifest.pod.name = pod_name.to_string();

View File

@ -104,11 +104,12 @@ Discovery is a read-only inventory operation. It may report package metadata, va
Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod. Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod.
A future enablement record can be shaped like this, but the exact schema belongs to the implementation Ticket: A minimal implemented enablement record is shaped like this. `version` is an exact package-version requirement; richer range constraints are deferred. `digest` is optional in authoring config, but fresh startup records the resolved digest into runtime metadata.
```toml ```toml
[[plugins.enabled]] [[plugins.enabled]]
id = "user:example" id = "user:example"
version = "0.1.0" # optional exact package-version requirement
digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata
config = { level = "concise" } config = { level = "concise" }
``` ```