filelift 0.1.0

A small CLI for lifting local files to S3-compatible object storage.
use std::{
    collections::BTreeMap,
    env, fs,
    path::{Path, PathBuf},
    sync::Mutex,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chacha20poly1305::{
    ChaCha20Poly1305, KeyInit, Nonce,
    aead::{Aead, OsRng, rand_core::RngCore},
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::{Event, Level, Subscriber, field::Visit};
use tracing_subscriber::{Layer, layer::Context as LayerContext, prelude::*, registry::LookupSpan};

use crate::{secret, target};

const LOG_KEY_ENV: &str = "FILELIFT_LOG_KEY_HEX";
const LOG_KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;

pub fn init() {
    let layer = DiagnosticLogLayer::default();
    let subscriber = tracing_subscriber::registry().with(layer);
    let _ = tracing::subscriber::set_global_default(subscriber);
}

pub fn log_path() -> Result<PathBuf> {
    Ok(target::filelift_home_dir()?
        .join("logs")
        .join("events.log.enc"))
}

pub fn export_to(path: &Path) -> Result<usize> {
    let events = read_events()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create log export directory at {}",
                parent.display()
            )
        })?;
    }

    let mut output = String::new();
    for event in &events {
        output.push_str(&serde_json::to_string(event).context("failed to serialize log event")?);
        output.push('\n');
    }

    fs::write(path, output).with_context(|| {
        format!(
            "failed to write diagnostic log export to {}",
            path.display()
        )
    })?;
    Ok(events.len())
}

pub fn clear() -> Result<()> {
    let path = log_path()?;
    if path.exists() {
        fs::remove_file(&path)
            .with_context(|| format!("failed to remove diagnostic log at {}", path.display()))?;
    }
    Ok(())
}

pub fn record_command_result(command: &str, target_name: Option<&str>, result: &str) {
    match target_name {
        Some(target_name) => {
            tracing::info!(command, target = target_name, result, "command finished");
        }
        None => {
            tracing::info!(command, result, "command finished");
        }
    }
}

fn read_events() -> Result<Vec<Value>> {
    let path = log_path()?;
    if !path.exists() {
        return Ok(Vec::new());
    }

    let content = fs::read_to_string(&path).with_context(|| {
        format!(
            "failed to read encrypted diagnostic log at {}",
            path.display()
        )
    })?;
    let key = log_key()?;
    let cipher = ChaCha20Poly1305::new_from_slice(&key).context("invalid diagnostic log key")?;
    let mut events = Vec::new();

    for line in content.lines().filter(|line| !line.trim().is_empty()) {
        let encrypted: EncryptedEvent =
            serde_json::from_str(line).context("failed to parse encrypted diagnostic log line")?;
        let nonce = STANDARD
            .decode(encrypted.nonce)
            .context("failed to decode diagnostic log nonce")?;
        let ciphertext = STANDARD
            .decode(encrypted.ciphertext)
            .context("failed to decode diagnostic log ciphertext")?;
        let plaintext = cipher
            .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
            .map_err(|_| anyhow::anyhow!("failed to decrypt diagnostic log event"))?;
        let event =
            serde_json::from_slice(&plaintext).context("failed to parse diagnostic log event")?;
        events.push(redact_event(event));
    }

    Ok(events)
}

fn append_event(event: Value) -> Result<()> {
    let path = log_path()?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create diagnostic log directory at {}",
                parent.display()
            )
        })?;
    }

    let key = log_key()?;
    let cipher = ChaCha20Poly1305::new_from_slice(&key).context("invalid diagnostic log key")?;
    let mut nonce = [0_u8; NONCE_LEN];
    OsRng.fill_bytes(&mut nonce);

    let plaintext =
        serde_json::to_vec(&event).context("failed to serialize diagnostic log event")?;
    let ciphertext = cipher
        .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
        .map_err(|_| anyhow::anyhow!("failed to encrypt diagnostic log event"))?;
    let encrypted = EncryptedEvent {
        nonce: STANDARD.encode(nonce),
        ciphertext: STANDARD.encode(ciphertext),
    };

    let mut line =
        serde_json::to_string(&encrypted).context("failed to serialize encrypted log event")?;
    line.push('\n');

    fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .with_context(|| format!("failed to open diagnostic log at {}", path.display()))?
        .write_all(line.as_bytes())
        .with_context(|| format!("failed to append diagnostic log at {}", path.display()))
}

fn log_key() -> Result<[u8; LOG_KEY_LEN]> {
    if let Ok(value) = env::var(LOG_KEY_ENV) {
        return decode_hex_key(&value);
    }

    match secret::diagnostic_log_key() {
        Ok(value) => decode_hex_key(&value),
        Err(_) => {
            let mut key = [0_u8; LOG_KEY_LEN];
            OsRng.fill_bytes(&mut key);
            let encoded = encode_hex(&key);
            secret::set_diagnostic_log_key(&encoded)?;
            Ok(key)
        }
    }
}

fn decode_hex_key(value: &str) -> Result<[u8; LOG_KEY_LEN]> {
    let value = value.trim();
    if value.len() != LOG_KEY_LEN * 2 {
        bail!("diagnostic log key must be 64 hex characters");
    }

    let mut key = [0_u8; LOG_KEY_LEN];
    for (index, byte) in key.iter_mut().enumerate() {
        let start = index * 2;
        *byte = u8::from_str_radix(&value[start..start + 2], 16)
            .context("diagnostic log key contains non-hex characters")?;
    }
    Ok(key)
}

fn encode_hex(bytes: &[u8; LOG_KEY_LEN]) -> String {
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

fn redact_event(mut event: Value) -> Value {
    if let Some(fields) = event.get_mut("fields").and_then(Value::as_object_mut) {
        for key in [
            "secret_access_key",
            "access_key_id",
            "authorization",
            "password",
            "token",
        ] {
            if fields.contains_key(key) {
                fields.insert(key.to_string(), Value::String("[redacted]".to_string()));
            }
        }
    }
    event
}

#[derive(Debug, Serialize, Deserialize)]
struct EncryptedEvent {
    nonce: String,
    ciphertext: String,
}

#[derive(Debug, Default)]
struct DiagnosticLogLayer {
    lock: Mutex<()>,
}

impl<S> Layer<S> for DiagnosticLogLayer
where
    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
{
    fn on_event(&self, event: &Event<'_>, _context: LayerContext<'_, S>) {
        if event.metadata().target().starts_with("aws_") {
            return;
        }

        let _guard = self.lock.lock().ok();
        let mut visitor = JsonVisitor::default();
        event.record(&mut visitor);

        let log_event = json!({
            "timestamp_ms": now_ms(),
            "level": level_name(event.metadata().level()),
            "target": event.metadata().target(),
            "filelift_version": env!("CARGO_PKG_VERSION"),
            "os": env::consts::OS,
            "fields": visitor.fields,
        });

        let _ = append_event(log_event);
    }
}

#[derive(Default)]
struct JsonVisitor {
    fields: BTreeMap<String, Value>,
}

impl Visit for JsonVisitor {
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        self.fields.insert(
            field.name().to_string(),
            Value::String(format!("{value:?}")),
        );
    }

    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        self.fields
            .insert(field.name().to_string(), Value::String(value.to_string()));
    }

    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
        self.fields
            .insert(field.name().to_string(), Value::Bool(value));
    }

    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
        self.fields
            .insert(field.name().to_string(), Value::Number(value.into()));
    }

    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
        self.fields
            .insert(field.name().to_string(), Value::Number(value.into()));
    }
}

fn level_name(level: &Level) -> &'static str {
    match *level {
        Level::ERROR => "error",
        Level::WARN => "warn",
        Level::INFO => "info",
        Level::DEBUG => "debug",
        Level::TRACE => "trace",
    }
}

fn now_ms() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis())
        .unwrap_or_default()
}

use std::io::Write;