use super::{TemplatePolicy, TemplateRecord};
use crate::services::lifecycle::Lifecycle;
use crate::services::lifecycle::site::SiteError;
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
pub(in super::super) struct StagedCommand {
directory: PathBuf,
command: PathBuf,
}
impl StagedCommand {
pub(in super::super) fn command(&self) -> &Path {
&self.command
}
fn create(parent: &Path) -> Result<Self, SiteError> {
let mut nonce = [0_u8; 32];
std::fs::File::open("/dev/urandom")
.and_then(|mut random| random.read_exact(&mut nonce))
.map_err(|_| SiteError::Invalid("template staging random source"))?;
let directory = parent.join(format!("approved-{}", hex::encode(nonce)));
std::fs::DirBuilder::new()
.mode(0o700)
.create(&directory)
.map_err(|_| SiteError::Invalid("template staging directory"))?;
Ok(Self {
command: directory.join("program"),
directory,
})
}
}
impl Drop for StagedCommand {
fn drop(&mut self) {
match std::fs::remove_file(&self.command) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => tracing::error!("template_staging_file_cleanup_failed"),
}
if std::fs::remove_dir(&self.directory).is_err() {
tracing::error!("template_staging_directory_cleanup_failed");
}
}
}
impl TemplatePolicy {
fn stage(&self, template: &TemplateRecord, parent: &Path) -> Result<StagedCommand, SiteError> {
let (_, source) = self.executable(&template.proposal.name, &template.proposal.command)?;
let staged = StagedCommand::create(parent)?;
let mut destination = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&staged.command)
.map_err(|_| SiteError::Invalid("template staging file"))?;
let mut source = source.take(self.command_bytes.get().saturating_add(1));
let mut hash = Sha256::new();
let mut buffer = [0_u8; 8192];
let mut total = 0_u64;
loop {
let n = source
.read(&mut buffer)
.map_err(|_| SiteError::Invalid("template command read"))?;
if n == 0 {
break;
}
total = total
.saturating_add(u64::try_from(n).map_err(|_| SiteError::Bound("command_bytes"))?);
if total > self.command_bytes.get() {
return Err(SiteError::Bound("command_bytes"));
}
let bytes = buffer.get(..n).ok_or(SiteError::Store)?;
hash.update(bytes);
destination
.write_all(bytes)
.map_err(|_| SiteError::Invalid("template staging write"))?;
}
let actual = hex::encode(hash.finalize());
if actual != template.proposal.digest {
return Err(digest_changed(template, &actual));
}
destination
.set_permissions(std::fs::Permissions::from_mode(0o500))
.map_err(|_| SiteError::Invalid("template staging permissions"))?;
destination
.sync_all()
.map_err(|_| SiteError::Invalid("template staging flush"))?;
drop(destination);
Ok(staged)
}
}
pub(super) fn digest_changed(template: &TemplateRecord, actual: &str) -> SiteError {
SiteError::Refused(format!(
"template {}: {} is not the file approved on {} (digest {} != {})",
template.proposal.name,
template.proposal.command.display(),
template.approved_at_ms,
template.proposal.digest,
actual
))
}
impl Lifecycle {
pub(in super::super) fn stage_template(
&self,
template: &TemplateRecord,
) -> Result<StagedCommand, SiteError> {
if template.retired {
return Err(SiteError::Refused(format!(
"template {} is retired",
template.proposal.name
)));
}
let policy = self
.site_bindings()?
.templates
.as_ref()
.ok_or(SiteError::Invalid("[auth.lys.templates] roots"))?;
policy.stage(template, &self.dir)
}
}