use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use serde_json::{Map, Value};
use crate::error::{self, Result};
use crate::event::{ArtifactId, ArtifactRef, Envelope, EventKind, Labels, Source};
use crate::{home, ids};
pub const ENVELOPE_VERSION: u32 = 1;
pub const PAYLOAD_LIMIT: usize = 4096;
pub const REDACTED: &str = "[redacted]";
const CREDENTIAL_WORDS: &[&str] = &[
"TOKEN",
"SECRET",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"APIKEY",
"API_KEY",
"PRIVATE_KEY",
];
const CREDENTIAL_PREFIXES: &[&str] = &[
"ghp_",
"gho_",
"ghs_",
"ghu_",
"ghr_",
"github_pat_",
"AKIA",
];
#[derive(Debug)]
pub struct Stream {
path: PathBuf,
id: String,
seq: u64,
labels: Labels,
}
impl Stream {
pub fn open(token: &str) -> Result<Self> {
let path = path_for(token)?;
home::ensure_dir(path.parent().expect("a stream lives in a directory"))?;
let seq = std::fs::read_to_string(&path)
.map(|raw| raw.lines().filter(|line| !line.trim().is_empty()).count() as u64)
.unwrap_or(0);
let mut labels = Labels::default();
labels
.extra
.insert("session".to_owned(), Value::String(token.to_owned()));
Ok(Self {
path,
id: token.to_owned(),
seq,
labels,
})
}
pub fn label(&mut self, key: &str, value: &str) {
self.labels
.extra
.insert(key.to_owned(), Value::String(value.to_owned()));
}
pub fn emit(&mut self, kind: EventKind, payload: Map<String, Value>) {
self.emit_with(kind, payload, Vec::new());
}
pub fn emit_with(
&mut self,
kind: EventKind,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
self.seq += 1;
let envelope = Envelope {
v: ENVELOPE_VERSION,
ts: ids::timestamp(),
stream: self.id.clone(),
seq: self.seq,
source: Source::Vcs,
kind,
labels: self.labels.clone(),
payload: bound(payload),
artifacts,
};
if let Err(error) = self.append(&envelope) {
eprintln!(
"onevcs: warning: cannot record a {kind:?} event in {}: {error}",
self.path.display()
);
}
}
fn append(&self, envelope: &Envelope) -> std::io::Result<()> {
let line = serde_json::to_string(envelope)?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
writeln!(file, "{line}")
}
}
pub fn path_for(token: &str) -> Result<PathBuf> {
if !ids::is_safe_name(token) {
return Err(error::invalid(format!("{token:?} is not a session token")));
}
Ok(home::streams_dir()?.join(format!("{token}.ndjson")))
}
fn bound(payload: Map<String, Value>) -> Map<String, Value> {
let mut bounded = Map::new();
let mut truncated = false;
for (key, value) in payload {
match value {
Value::String(text) => {
let clean = redact(&text);
if clean.len() > PAYLOAD_LIMIT {
truncated = true;
let cut = floor_char_boundary(&clean, PAYLOAD_LIMIT);
bounded.insert(key, Value::String(clean[..cut].to_owned()));
} else {
bounded.insert(key, Value::String(clean));
}
}
other => {
bounded.insert(key, other);
}
}
}
if truncated {
bounded.insert("truncated".to_owned(), Value::Bool(true));
}
bounded
}
fn floor_char_boundary(value: &str, at: usize) -> usize {
let mut index = at.min(value.len());
while index > 0 && !value.is_char_boundary(index) {
index -= 1;
}
index
}
pub fn redact(text: &str) -> String {
let mut clean = text.to_owned();
for (name, value) in std::env::vars() {
let upper = name.to_ascii_uppercase();
if value.len() >= 8 && CREDENTIAL_WORDS.iter().any(|word| upper.contains(word)) {
clean = clean.replace(&value, REDACTED);
}
}
clean
.split_inclusive(|c: char| c.is_whitespace())
.map(|word| {
let trimmed = word.trim_end();
let spacing = &word[trimmed.len()..];
let bare = trimmed.trim_end_matches(['"', '\'', ',', ';', ')']);
let punctuation = &trimmed[bare.len()..];
if CREDENTIAL_PREFIXES
.iter()
.any(|prefix| bare.starts_with(prefix) && bare.len() >= prefix.len() + 8)
{
format!("{REDACTED}{punctuation}{spacing}")
} else {
word.to_owned()
}
})
.collect()
}
pub fn store_artifact(kind: &str, contents: &str) -> Result<ArtifactRef> {
let id = ids::artifact_id();
let directory = home::artifacts_dir()?;
home::ensure_dir(&directory)?;
let clean = redact(contents);
let path = directory.join(&id);
std::fs::write(&path, &clean).map_err(error::at("store the artifact at", &path))?;
Ok(ArtifactRef {
id: ArtifactId(id),
kind: kind.to_owned(),
bytes: clean.len() as u64,
})
}
pub fn read_artifact(id: &str) -> Result<String> {
if !ids::is_safe_name(id) {
return Err(error::invalid(format!("{id:?} is not an artifact id")));
}
let path = home::artifacts_dir()?.join(id);
std::fs::read_to_string(&path)
.map_err(|_| error::invalid(format!("no artifact {id:?} is stored")))
}