monosecret 0.3.3

Declarative secrets, every environment, any provider
Documentation
use std::ffi::OsStr;
use std::fs;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;

use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;

use super::Address;
use super::Provider;
use super::ProviderUrl;
use crate::MonosecretError;
use crate::Result;

const CREDENTIALS_DIRECTORY_ENV: &str = "CREDENTIALS_DIRECTORY";

/// Configuration for the read-only systemd service-credential provider.
///
/// The provider takes no URI options. systemd supplies the credential
/// directory through `$CREDENTIALS_DIRECTORY` when it starts the process.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SystemdCredentialConfig {}

impl TryFrom<&ProviderUrl> for SystemdCredentialConfig {
	type Error = MonosecretError;

	fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
		if url.scheme() != "systemd-credential" {
			return Err(MonosecretError::ProviderOperationFailed(format!(
				"Invalid scheme '{}' for systemd-credential provider",
				url.scheme()
			)));
		}

		let host = url.host().filter(|host| !host.is_empty());
		let path = url.path();
		let path_item = path.trim_matches('/');
		if !url.username().is_empty()
			|| url.password().is_some()
			|| host.is_some()
			|| !path_item.is_empty()
			|| url.has_query()
		{
			let item = host
				.as_deref()
				.or((!path_item.is_empty()).then_some(path_item));
			let hint = item.map_or_else(
				|| "ref = { item = \"CREDENTIAL_NAME\" }".to_string(),
				|item| crate::config::ref_table_hint(None, item, None, None),
			);
			return Err(MonosecretError::ProviderOperationFailed(format!(
				"systemd-credential:// takes no authority, path, or query: to read one \
                 specific credential, use {hint} on the secret instead"
			)));
		}

		Ok(Self {})
	}
}

/// Reads credentials systemd passed to the current service.
///
/// systemd exposes each credential as an immutable file beneath the absolute
/// directory named by `$CREDENTIALS_DIRECTORY`. Convention addresses map
/// directly to the secret key, while a native address reads the credential
/// named by its `item` coordinate. The provider is read-only because the
/// directory is owned and populated by the service manager.
pub struct SystemdCredentialProvider {
	credentials_directory: Option<PathBuf>,
}

crate::register_provider! {
	struct: SystemdCredentialProvider,
	config: SystemdCredentialConfig,
	name: "systemd-credential",
	description: "Read-only systemd service credentials (0.17+)",
	schemes: ["systemd-credential"],
	examples: ["systemd-credential://"],
}

impl SystemdCredentialProvider {
	pub fn new(_config: SystemdCredentialConfig) -> Self {
		Self {
			credentials_directory: std::env::var_os(CREDENTIALS_DIRECTORY_ENV).map(PathBuf::from),
		}
	}

	#[cfg(test)]
	fn with_directory(credentials_directory: Option<PathBuf>) -> Self {
		Self {
			credentials_directory,
		}
	}

	fn directory(&self) -> Result<&Path> {
		let directory = self.credentials_directory.as_deref().ok_or_else(|| {
			MonosecretError::ProviderOperationFailed(format!(
				"{CREDENTIALS_DIRECTORY_ENV} is not set; the systemd-credential provider \
                 only works inside a service that systemd started with credentials"
			))
		})?;

		if !directory.is_absolute() {
			return Err(MonosecretError::ProviderOperationFailed(format!(
				"{CREDENTIALS_DIRECTORY_ENV} must contain an absolute path, got '{}'",
				directory.display()
			)));
		}

		Ok(directory)
	}

	fn credential_path(&self, name: &str) -> Result<PathBuf> {
		let mut components = Path::new(name).components();
		let is_one_normal_component = matches!(
			(components.next(), components.next()),
			(Some(Component::Normal(component)), None) if component == OsStr::new(name)
		);
		if name.is_empty()
			|| name.len() > 255
			|| name.contains(['/', '\\', '\0'])
			|| !is_one_normal_component
		{
			return Err(MonosecretError::ProviderOperationFailed(format!(
				"invalid systemd credential name '{name}': expected one filename of at most 255 bytes"
			)));
		}

		Ok(self.directory()?.join(name))
	}
}

impl Provider for SystemdCredentialProvider {
	/// systemd credentials form one flat namespace for the service, so project
	/// and profile do not participate in the filename.
	fn convention_address(
		&self,
		_project: &str,
		_profile: &str,
		key: &str,
	) -> Result<crate::config::NativeAddress> {
		Ok(crate::config::NativeAddress {
			item: key.to_string(),
			..Default::default()
		})
	}

	fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
		let name = super::flat_item(self, addr)?;
		let path = self.credential_path(&name)?;

		let metadata = match fs::symlink_metadata(&path) {
			Ok(metadata) => metadata,
			Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
			Err(error) => {
				return Err(MonosecretError::ProviderOperationFailed(format!(
					"failed to inspect systemd credential '{}': {error}",
					path.display()
				)));
			}
		};

		if !metadata.file_type().is_file() {
			return Err(MonosecretError::ProviderOperationFailed(format!(
				"systemd credential '{}' is not a regular file",
				path.display()
			)));
		}

		let value = fs::read_to_string(&path).map_err(|error| {
			MonosecretError::ProviderOperationFailed(format!(
				"failed to read systemd credential '{}': {error}",
				path.display()
			))
		})?;
		Ok(Some(SecretString::new(value.into())))
	}

	fn set(&self, addr: Address<'_>, _value: &SecretString) -> Result<()> {
		self.check_writable(addr)
	}

	fn check_writable(&self, _addr: Address<'_>) -> Result<()> {
		Err(MonosecretError::ProviderOperationFailed(
			"systemd-credential provider is read-only; configure credentials with \
             LoadCredential=, LoadCredentialEncrypted=, SetCredential=, or \
             SetCredentialEncrypted= in the service unit"
				.to_string(),
		))
	}

	fn name(&self) -> &'static str {
		Self::PROVIDER_NAME
	}

	fn uri(&self) -> String {
		"systemd-credential".to_string()
	}
}

#[cfg(test)]
mod tests {
	use secrecy::ExposeSecret;
	use tempfile::TempDir;
	use url::Url;

	use super::*;

	fn provider(directory: &Path) -> SystemdCredentialProvider {
		SystemdCredentialProvider::with_directory(Some(directory.to_path_buf()))
	}

	#[test]
	fn convention_address_reads_exact_value_without_trimming() {
		let directory = TempDir::new().unwrap();
		fs::write(directory.path().join("API_TOKEN"), "line one\nline two\n").unwrap();

		let value = provider(directory.path())
			.get(Address::convention("project", "production", "API_TOKEN"))
			.unwrap()
			.unwrap();

		assert_eq!(value.expose_secret(), "line one\nline two\n");
	}

	#[test]
	fn native_address_reads_the_item_name() {
		let directory = TempDir::new().unwrap();
		fs::write(directory.path().join("op-token"), "ops_example").unwrap();
		let address = crate::config::NativeAddress {
			item: "op-token".to_string(),
			..Default::default()
		};

		let value = provider(directory.path())
			.get(Address::Native(&address))
			.unwrap()
			.unwrap();

		assert_eq!(value.expose_secret(), "ops_example");
	}

	#[test]
	fn missing_credential_is_absent() {
		let directory = TempDir::new().unwrap();
		assert!(
			provider(directory.path())
				.get(Address::convention("project", "default", "MISSING"))
				.unwrap()
				.is_none()
		);
	}

	#[test]
	fn missing_directory_environment_is_actionable() {
		let error = SystemdCredentialProvider::with_directory(None)
			.get(Address::convention("project", "default", "TOKEN"))
			.unwrap_err();
		assert!(
			error.to_string().contains(CREDENTIALS_DIRECTORY_ENV),
			"{error}"
		);
		assert!(error.to_string().contains("systemd"), "{error}");
	}

	#[test]
	fn relative_directory_is_rejected() {
		let error =
			SystemdCredentialProvider::with_directory(Some(PathBuf::from("relative/credentials")))
				.get(Address::convention("project", "default", "TOKEN"))
				.unwrap_err();
		assert!(error.to_string().contains("absolute path"), "{error}");
	}

	#[test]
	fn traversal_and_nested_paths_are_rejected() {
		let directory = TempDir::new().unwrap();
		for name in [
			"../secret",
			"nested/secret",
			r"nested\secret",
			".",
			"..",
			"",
		] {
			let address = crate::config::NativeAddress {
				item: name.to_string(),
				..Default::default()
			};
			let error = provider(directory.path())
				.get(Address::Native(&address))
				.unwrap_err();
			assert!(
				error
					.to_string()
					.contains("invalid systemd credential name"),
				"{name:?}: {error}"
			);
		}
	}

	#[cfg(unix)]
	#[test]
	fn symlink_is_rejected() {
		use std::os::unix::fs::symlink;

		let directory = TempDir::new().unwrap();
		let outside = TempDir::new().unwrap();
		let target = outside.path().join("target");
		fs::write(&target, "must not be read").unwrap();
		symlink(&target, directory.path().join("TOKEN")).unwrap();

		let error = provider(directory.path())
			.get(Address::convention("project", "default", "TOKEN"))
			.unwrap_err();
		assert!(error.to_string().contains("not a regular file"), "{error}");
	}

	#[test]
	fn binary_credential_is_rejected_by_text_provider_api() {
		let directory = TempDir::new().unwrap();
		fs::write(directory.path().join("TOKEN"), [0xff, 0xfe]).unwrap();

		let error = provider(directory.path())
			.get(Address::convention("project", "default", "TOKEN"))
			.unwrap_err();
		assert!(error.to_string().contains("failed to read"), "{error}");
		assert!(error.to_string().contains("UTF-8"), "{error}");
	}

	#[test]
	fn unsupported_native_coordinate_is_rejected() {
		let directory = TempDir::new().unwrap();
		let address = crate::config::NativeAddress {
			item: "TOKEN".to_string(),
			field: Some("password".to_string()),
			..Default::default()
		};

		let error = provider(directory.path())
			.get(Address::Native(&address))
			.unwrap_err();
		assert!(error.to_string().contains("`field`"), "{error}");
	}

	#[test]
	fn provider_is_read_only() {
		let directory = TempDir::new().unwrap();
		let provider = provider(directory.path());
		let address = Address::convention("project", "default", "TOKEN");
		let check_error = provider.check_writable(address).unwrap_err().to_string();
		let set_error = provider
			.set(address, &SecretString::new("value".into()))
			.unwrap_err()
			.to_string();

		assert_eq!(check_error, set_error);
		assert!(check_error.contains("read-only"), "{check_error}");
	}

	#[test]
	fn uri_must_not_select_a_credential() {
		for uri in [
			"systemd-credential://TOKEN",
			"systemd-credential:///TOKEN",
			"systemd-credential://?name=TOKEN",
		] {
			let error =
				SystemdCredentialConfig::try_from(&ProviderUrl::new(Url::parse(uri).unwrap()))
					.unwrap_err();
			assert!(
				error.to_string().contains("takes no authority"),
				"{uri}: {error}"
			);
		}
	}
}