use std::{error::Error, fmt};
use crate::{model::ComposeDocument, source::SourceId, syntax::SyntaxDocument};
use super::write_quoted;
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GenerationError {
EmptyValue(&'static str),
ContainsNul(&'static str),
InvalidEnvironmentName,
InvalidShortComponent(&'static str),
InvalidSelinuxBind,
DuplicateField(&'static str),
DuplicateName {
kind: &'static str,
name: String,
},
InvalidPort,
UnrepresentableSctpHostIp,
MissingService,
InternalInvariant(&'static str),
}
impl fmt::Display for GenerationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
Self::InvalidShortComponent(kind) => {
write!(formatter, "generated {kind} contains its reserved short-form separator")
}
Self::InvalidSelinuxBind => formatter
.write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
Self::DuplicateName { kind, name } => {
write!(formatter, "generated {kind} `{name}` was added more than once")
}
Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
Self::UnrepresentableSctpHostIp => formatter.write_str(
"generated SCTP port with a host address also requires a published port for Compose short syntax",
),
Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
}
}
}
impl Error for GenerationError {}
#[derive(Clone, Eq, PartialEq)]
pub struct GeneratedString {
value: String,
sensitive: bool,
}
impl GeneratedString {
pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
Self::new(value.into(), false)
}
pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
Self::new(value.into(), true)
}
fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
if value.contains('\0') {
return Err(GenerationError::ContainsNul("string"));
}
Ok(Self { value, sensitive })
}
#[must_use]
pub fn expose(&self) -> &str {
&self.value
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for GeneratedString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GeneratedString")
.field("value", &if self.sensitive { "<redacted>" } else { &self.value })
.field("sensitive", &self.sensitive)
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedCommand {
Exec(Vec<GeneratedString>),
Shell(GeneratedString),
Empty,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedEnvironment {
name: String,
value: Option<GeneratedString>,
}
impl GeneratedEnvironment {
pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
Ok(Self {
name: environment_name(name.into())?,
value: Some(value),
})
}
pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: environment_name(name.into())?,
value: None,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> Option<&GeneratedString> {
self.value.as_ref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedExtraHost {
hostname: String,
address: String,
}
impl GeneratedExtraHost {
pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
let address = short_component("extra-host address", address.into(), '=')?;
Ok(Self { hostname, address })
}
#[must_use]
pub fn hostname(&self) -> &str {
&self.hostname
}
#[must_use]
pub fn address(&self) -> &str {
&self.address
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedProtocol {
Tcp,
Udp,
Sctp,
}
impl GeneratedProtocol {
const fn as_str(self) -> &'static str {
match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
Self::Sctp => "sctp",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedPort {
target: u16,
published: Option<u16>,
host_ip: Option<String>,
protocol: GeneratedProtocol,
}
impl GeneratedPort {
pub fn new(
target: u16,
published: Option<u16>,
host_ip: Option<String>,
protocol: GeneratedProtocol,
) -> Result<Self, GenerationError> {
if target == 0 {
return Err(GenerationError::InvalidPort);
}
if let Some(host_ip) = host_ip.as_deref() {
required("port host address", host_ip.to_owned())?;
if protocol == GeneratedProtocol::Sctp && published.is_none() {
return Err(GenerationError::UnrepresentableSctpHostIp);
}
}
Ok(Self {
target,
published,
host_ip,
protocol,
})
}
#[must_use]
pub const fn target(&self) -> u16 {
self.target
}
#[must_use]
pub const fn published(&self) -> Option<u16> {
self.published
}
#[must_use]
pub fn host_ip(&self) -> Option<&str> {
self.host_ip.as_deref()
}
#[must_use]
pub const fn protocol(&self) -> GeneratedProtocol {
self.protocol
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedSelinux {
Private,
Shared,
}
impl GeneratedSelinux {
const fn as_str(self) -> &'static str {
match self {
Self::Private => "Z",
Self::Shared => "z",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum GeneratedMountKind {
Volume {
source: String,
},
Bind {
source: String,
selinux: Option<GeneratedSelinux>,
},
Anonymous,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedMount {
kind: GeneratedMountKind,
target: String,
read_only: bool,
}
impl GeneratedMount {
pub fn volume(
source: impl Into<String>,
target: impl Into<String>,
read_only: bool,
) -> Result<Self, GenerationError> {
Ok(Self {
kind: GeneratedMountKind::Volume {
source: required("volume source", source.into())?,
},
target: required("mount target", target.into())?,
read_only,
})
}
pub fn bind(
source: impl Into<String>,
target: impl Into<String>,
read_only: bool,
selinux: Option<GeneratedSelinux>,
) -> Result<Self, GenerationError> {
let source = required("bind source", source.into())?;
let target = required("mount target", target.into())?;
if selinux.is_some() && (source.contains(':') || target.contains(':')) {
return Err(GenerationError::InvalidSelinuxBind);
}
Ok(Self {
kind: GeneratedMountKind::Bind { source, selinux },
target,
read_only,
})
}
pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
Ok(Self {
kind: GeneratedMountKind::Anonymous,
target: required("mount target", target.into())?,
read_only,
})
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub const fn read_only(&self) -> bool {
self.read_only
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedNetworkAttachment {
name: String,
aliases: Vec<String>,
}
impl GeneratedNetworkAttachment {
pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("network name", name.into())?,
aliases: Vec::new(),
})
}
pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
self.aliases.push(required("network alias", alias.into())?);
Ok(())
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn aliases(&self) -> &[String] {
&self.aliases
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedResource {
name: String,
external: bool,
custom_name: Option<String>,
}
impl GeneratedResource {
pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("resource name", name.into())?,
external: false,
custom_name: None,
})
}
pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("resource name", name.into())?,
external: true,
custom_name: None,
})
}
pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
let name = required("custom resource name", name.into())?;
set_once(&mut self.custom_name, name, "resource name")
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn is_external(&self) -> bool {
self.external
}
#[must_use]
pub fn custom_name(&self) -> Option<&str> {
self.custom_name.as_deref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedService {
name: String,
image: Option<GeneratedString>,
command: Option<GeneratedCommand>,
environment: Vec<GeneratedEnvironment>,
user: Option<GeneratedString>,
userns_mode: Option<GeneratedString>,
group_add: Vec<GeneratedString>,
working_dir: Option<GeneratedString>,
read_only: Option<bool>,
extra_hosts: Vec<GeneratedExtraHost>,
ports: Vec<GeneratedPort>,
mounts: Vec<GeneratedMount>,
networks: Vec<GeneratedNetworkAttachment>,
}
impl GeneratedService {
pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("service name", name.into())?,
image: None,
command: None,
environment: Vec::new(),
user: None,
userns_mode: None,
group_add: Vec::new(),
working_dir: None,
read_only: None,
extra_hosts: Vec::new(),
ports: Vec::new(),
mounts: Vec::new(),
networks: Vec::new(),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("service image", &image)?;
set_once(&mut self.image, image, "image")
}
pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
set_once(&mut self.command, command, "command")
}
pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
self.environment.push(environment);
}
pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
set_once(&mut self.user, user, "user")
}
pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("user namespace mode", &mode)?;
set_once(&mut self.userns_mode, mode, "userns_mode")
}
pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("supplementary group", &group)?;
self.group_add.push(group);
Ok(())
}
pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("working directory", &directory)?;
set_once(&mut self.working_dir, directory, "working_dir")
}
pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
set_once(&mut self.read_only, read_only, "read_only")
}
pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
self.extra_hosts.push(host);
}
pub fn add_port(&mut self, port: GeneratedPort) {
self.ports.push(port);
}
pub fn add_mount(&mut self, mount: GeneratedMount) {
self.mounts.push(mount);
}
pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
if self.networks.iter().any(|candidate| candidate.name == network.name) {
return Err(GenerationError::DuplicateName {
kind: "service network",
name: network.name,
});
}
self.networks.push(network);
Ok(())
}
fn is_sensitive(&self) -> bool {
self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
|| self.command.as_ref().is_some_and(command_is_sensitive)
|| self
.environment
.iter()
.filter_map(GeneratedEnvironment::value)
.any(GeneratedString::is_sensitive)
|| [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
.into_iter()
.flatten()
.any(GeneratedString::is_sensitive)
|| self.group_add.iter().any(GeneratedString::is_sensitive)
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ComposeDocumentBuilder {
name: Option<String>,
services: Vec<GeneratedService>,
networks: Vec<GeneratedResource>,
volumes: Vec<GeneratedResource>,
}
impl ComposeDocumentBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
name: None,
services: Vec::new(),
networks: Vec::new(),
volumes: Vec::new(),
}
}
pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
let name = required("project name", name.into())?;
set_once(&mut self.name, name, "name")
}
pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
insert_named(&mut self.services, service, "service", GeneratedService::name)
}
pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
insert_named(&mut self.networks, network, "network", GeneratedResource::name)
}
pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
}
pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
if self.services.is_empty() {
return Err(GenerationError::MissingService);
}
let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
let text = render_document(&self);
let syntax = SyntaxDocument::parse(source_id, text.clone())
.map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
if !syntax.is_valid() {
return Err(GenerationError::InternalInvariant("syntax"));
}
let model = ComposeDocument::parse(syntax.document());
if !model.is_valid() {
return Err(GenerationError::InternalInvariant("typed-model"));
}
let document = model
.document()
.cloned()
.ok_or(GenerationError::InternalInvariant("document-root"))?;
Ok(GeneratedComposeDocument {
text,
sensitive,
document,
})
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct GeneratedComposeDocument {
text: String,
sensitive: bool,
document: ComposeDocument,
}
impl GeneratedComposeDocument {
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn document(&self) -> &ComposeDocument {
&self.document
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for GeneratedComposeDocument {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GeneratedComposeDocument")
.field("text", &if self.sensitive { "<redacted>" } else { &self.text })
.field("sensitive", &self.sensitive)
.field("document", &if self.sensitive { "<redacted>" } else { "validated" })
.finish()
}
}
fn render_document(project: &ComposeDocumentBuilder) -> String {
let mut output = String::new();
if let Some(name) = &project.name {
output.push_str("name: ");
write_quoted(&mut output, name);
output.push('\n');
}
output.push_str("services:\n");
for service in &project.services {
write_indent(&mut output, 1);
write_quoted(&mut output, &service.name);
output.push_str(":\n");
render_service(&mut output, service);
}
render_resources(&mut output, "networks", &project.networks);
render_resources(&mut output, "volumes", &project.volumes);
output
}
fn render_service(output: &mut String, service: &GeneratedService) {
render_optional_string(output, "image", service.image.as_ref());
if let Some(command) = &service.command {
render_command(output, command);
}
render_environment(output, &service.environment);
render_optional_string(output, "user", service.user.as_ref());
render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
render_string_sequence(output, "group_add", &service.group_add);
render_optional_string(output, "working_dir", service.working_dir.as_ref());
if let Some(read_only) = service.read_only {
write_field(output, 2, "read_only");
output.push_str(if read_only { "true\n" } else { "false\n" });
}
render_extra_hosts(output, &service.extra_hosts);
render_ports(output, &service.ports);
render_mounts(output, &service.mounts);
render_networks(output, &service.networks);
}
fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
if let Some(value) = value {
write_field(output, 2, key);
write_quoted(output, value.expose());
output.push('\n');
}
}
fn render_command(output: &mut String, command: &GeneratedCommand) {
match command {
GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
GeneratedCommand::Empty => output.push_str(" command: []\n"),
}
}
fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
if environment.is_empty() {
return;
}
output.push_str(" environment:\n");
for variable in environment {
output.push_str(" - ");
let value = variable.value.as_ref().map_or_else(
|| variable.name.clone(),
|value| format!("{}={}", variable.name, value.expose()),
);
write_quoted(output, &value);
output.push('\n');
}
}
fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
if values.is_empty() {
return;
}
write_indent(output, 2);
output.push_str(key);
output.push_str(":\n");
for value in values {
output.push_str(" - ");
write_quoted(output, value.expose());
output.push('\n');
}
}
fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
if hosts.is_empty() {
return;
}
output.push_str(" extra_hosts:\n");
for host in hosts {
output.push_str(" - ");
write_quoted(output, &format!("{}={}", host.hostname, host.address));
output.push('\n');
}
}
fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
if ports.is_empty() {
return;
}
output.push_str(" ports:\n");
for port in ports {
if port.protocol == GeneratedProtocol::Sctp {
render_short_sctp_port(output, port);
continue;
}
output.push_str(" - target: ");
output.push_str(&port.target.to_string());
output.push('\n');
if let Some(published) = port.published {
output.push_str(" published: ");
write_quoted(output, &published.to_string());
output.push('\n');
}
if let Some(host_ip) = &port.host_ip {
output.push_str(" host_ip: ");
write_quoted(output, host_ip);
output.push('\n');
}
output.push_str(" protocol: ");
write_quoted(output, port.protocol.as_str());
output.push('\n');
}
}
fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
let mut value = String::new();
if let Some(host_ip) = &port.host_ip {
if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
value.push('[');
value.push_str(host_ip);
value.push(']');
} else {
value.push_str(host_ip);
}
value.push(':');
}
if let Some(published) = port.published {
value.push_str(&published.to_string());
value.push(':');
}
value.push_str(&port.target.to_string());
value.push_str("/sctp");
output.push_str(" - ");
write_quoted(output, &value);
output.push('\n');
}
fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
if mounts.is_empty() {
return;
}
output.push_str(" volumes:\n");
for mount in mounts {
match &mount.kind {
GeneratedMountKind::Bind {
source,
selinux: Some(selinux),
} => render_selinux_bind(output, source, mount, *selinux),
kind => render_long_mount(output, kind, mount),
}
}
}
fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
if mount.read_only {
value.push_str(",ro");
}
output.push_str(" - ");
write_quoted(output, &value);
output.push('\n');
}
fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
let (mount_type, source) = match kind {
GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
GeneratedMountKind::Anonymous => ("volume", None),
GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
};
output.push_str(" - type: ");
write_quoted(output, mount_type);
output.push('\n');
if let Some(source) = source {
output.push_str(" source: ");
write_quoted(output, source);
output.push('\n');
}
output.push_str(" target: ");
write_quoted(output, &mount.target);
output.push('\n');
if mount.read_only {
output.push_str(" read_only: true\n");
}
}
fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
if networks.is_empty() {
return;
}
output.push_str(" networks:\n");
for network in networks {
output.push_str(" ");
write_quoted(output, &network.name);
if network.aliases.is_empty() {
output.push_str(": {}\n");
} else {
output.push_str(":\n aliases:\n");
for alias in &network.aliases {
output.push_str(" - ");
write_quoted(output, alias);
output.push('\n');
}
}
}
}
fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
if resources.is_empty() {
return;
}
output.push_str(section);
output.push_str(":\n");
for resource in resources {
output.push_str(" ");
write_quoted(output, &resource.name);
if !resource.external && resource.custom_name.is_none() {
output.push_str(": {}\n");
continue;
}
output.push_str(":\n");
if let Some(custom_name) = &resource.custom_name {
output.push_str(" name: ");
write_quoted(output, custom_name);
output.push('\n');
}
if resource.external {
output.push_str(" external: true\n");
}
}
}
fn write_field(output: &mut String, depth: usize, key: &str) {
write_indent(output, depth);
output.push_str(key);
output.push_str(": ");
}
fn write_indent(output: &mut String, depth: usize) {
for _ in 0..depth {
output.push_str(" ");
}
}
fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
if value.is_empty() {
return Err(GenerationError::EmptyValue(kind));
}
if value.contains('\0') {
return Err(GenerationError::ContainsNul(kind));
}
Ok(value)
}
fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
if value.expose().is_empty() {
return Err(GenerationError::EmptyValue(kind));
}
Ok(())
}
fn environment_name(value: String) -> Result<String, GenerationError> {
let value = required("environment name", value)?;
if value.contains('=') {
return Err(GenerationError::InvalidEnvironmentName);
}
Ok(value)
}
fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
let value = required(kind, value)?;
if value.contains(separator) {
return Err(GenerationError::InvalidShortComponent(kind));
}
Ok(value)
}
fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
if slot.is_some() {
return Err(GenerationError::DuplicateField(field));
}
*slot = Some(value);
Ok(())
}
fn insert_named<T>(
values: &mut Vec<T>,
value: T,
kind: &'static str,
name: impl Fn(&T) -> &str,
) -> Result<(), GenerationError> {
let value_name = name(&value);
if values.iter().any(|candidate| name(candidate) == value_name) {
return Err(GenerationError::DuplicateName {
kind,
name: value_name.to_owned(),
});
}
values.push(value);
Ok(())
}
fn command_is_sensitive(command: &GeneratedCommand) -> bool {
match command {
GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
GeneratedCommand::Shell(command) => command.is_sensitive(),
GeneratedCommand::Empty => false,
}
}