use std::collections::BTreeSet;
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, EventFilter, EventKind, Labels, Line, Phase, Source,
};
use crate::git::ObjectId;
use crate::landed::Landed;
use crate::rules::MergePolicy;
use crate::session::SessionToken;
use crate::{home, ids, lock, policy, release, status, store, workspace};
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,
shared: bool,
}
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 = recorded(&path);
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,
shared: false,
})
}
pub fn releases(identity: &str) -> Result<Self> {
let mut stream = Self::open(&releases_token(identity))?;
stream.labels.extra.remove("session");
stream.label("identity", identity);
stream.shared = true;
Ok(stream)
}
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_push(
&mut self,
phase: Phase,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
self.append_stamped(EventKind::Push, phase, payload, artifacts);
}
pub fn emit_with(
&mut self,
kind: EventKind,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
let phase = match Phase::of(kind) {
Some(phase) => phase,
None => Phase::Development,
};
self.append_stamped(kind, phase, payload, artifacts);
}
fn append_stamped(
&mut self,
kind: EventKind,
phase: Phase,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
let _turn = match self.shared {
true => match lock::exclusive(&stream_identity(&self.id)) {
Ok(turn) => Some(turn),
Err(error) => {
eprintln!(
"onevcs: warning: cannot order a {kind:?} event in {}: {error}",
self.path.display()
);
None
}
},
false => None,
};
if self.shared {
self.seq = recorded(&self.path);
}
self.seq += 1;
let envelope = Envelope {
v: ENVELOPE_VERSION,
ts: ids::timestamp(),
stream: self.id.clone(),
seq: self.seq,
source: Source::Vcs,
kind,
phase,
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 mut line = serde_json::to_string(envelope)?;
line.push('\n');
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(line.as_bytes())
}
}
#[derive(Debug)]
pub struct Reader {
path: PathBuf,
read: usize,
}
impl Reader {
pub fn open(token: &str) -> Result<Self> {
let path = path_for(token)?;
if !path.is_file() {
return Err(error::invalid(format!("no event stream for {token:?}")));
}
Ok(Self { path, read: 0 })
}
pub fn lines(&mut self) -> Result<Vec<String>> {
let raw = std::fs::read_to_string(&self.path).map_err(error::at("read", &self.path))?;
let lines: Vec<&str> = raw.lines().collect();
let fresh = lines
.iter()
.skip(self.read)
.map(|line| (*line).to_owned())
.collect();
self.read = lines.len();
Ok(fresh)
}
}
#[derive(Debug)]
pub struct EventStream {
session: SessionToken,
reader: Reader,
filter: EventFilter,
phases: BTreeSet<Phase>,
releases: Option<Correlated>,
}
#[derive(Debug)]
struct Correlated {
identity: String,
token: String,
landing: Option<ObjectId>,
handed: BTreeSet<u64>,
weighed: BTreeSet<u64>,
}
impl EventStream {
pub fn open(session: &SessionToken) -> Result<Self> {
Self::open_filtered(session, EventFilter::default())
}
pub fn open_filtered(session: &SessionToken, filter: EventFilter) -> Result<Self> {
let reader = Reader::open(&session.0)?;
let (phases, identity) = supported(session);
for named in filter
.include
.iter()
.chain(&filter.exclude)
.filter_map(|matcher| matcher.phase)
{
if !phases.contains(&named) {
return Err(error::invalid(format!(
"the event filter names the {named} phase, which the session {session} does \
not have: it has {had}. A filter that named it would be answered with \
nothing, and nothing is what a filter for the wrong phase and a session \
that did nothing look alike as",
session = session.0,
had = listed(&phases),
)));
}
}
let releases = match (phases.contains(&Phase::Release), identity) {
(true, Some(identity)) => Some(Correlated {
token: releases_token(&identity),
identity,
landing: None,
handed: BTreeSet::new(),
weighed: BTreeSet::new(),
}),
_ => None,
};
Ok(Self {
session: session.clone(),
reader,
filter,
phases,
releases,
})
}
#[must_use]
pub fn session(&self) -> &SessionToken {
&self.session
}
pub fn read(&mut self) -> Result<Vec<Envelope>> {
let mut events = Vec::new();
let mut line_number = self.reader.read;
for line in self.reader.lines()? {
line_number += 1;
let Line::Known(envelope) = attributed(&line, &self.session.0, line_number)? else {
continue;
};
if !self.filter.matches(&envelope) {
continue;
}
if !self.phases.contains(&envelope.phase) {
continue;
}
events.push(*envelope);
}
if let Some(correlated) = &mut self.releases {
events.extend(correlated.fresh(&self.session, &self.filter)?);
}
Ok(events)
}
}
impl Correlated {
fn fresh(&mut self, session: &SessionToken, filter: &EventFilter) -> Result<Vec<Envelope>> {
let path = path_for(&self.token)?;
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(failure) => {
return Err(error::invalid(format!(
"the release record for {identity} cannot be read: {failure}",
identity = self.identity,
)))
}
};
let mut candidates = Vec::new();
for (index, line) in raw.lines().enumerate() {
let envelope = self.attributed(line, index + 1)?;
if !matches!(
envelope.kind,
EventKind::ReleaseObserved | EventKind::ReleaseAcknowledged
) || self.handed.contains(&envelope.seq)
{
continue;
}
candidates.push((index + 1, envelope));
}
let weighing: BTreeSet<u64> = candidates
.iter()
.map(|(_, envelope)| envelope.seq)
.collect();
if weighing.is_empty() || (self.landing.is_none() && weighing == self.weighed) {
return Ok(Vec::new());
}
self.weighed = weighing;
let landing = match &self.landing {
Some(landing) => landing.clone(),
None => match landed_at(session) {
None => return Ok(Vec::new()),
Some(landing) => {
self.landing = Some(landing.clone());
landing
}
},
};
let mut fresh = Vec::new();
for (line_number, envelope) in candidates {
let named = envelope
.payload
.get("landing_commit")
.and_then(Value::as_str)
.and_then(ObjectId::parse)
.ok_or_else(|| {
self.refusal(
line_number,
"records a release that names no landing commit, so nothing can be said \
about which work it released",
)
})?;
if named != landing {
continue;
}
self.handed.insert(envelope.seq);
if filter.matches(&envelope) {
fresh.push(envelope);
}
}
Ok(fresh)
}
fn attributed(&self, line: &str, line_number: usize) -> Result<Envelope> {
let envelope: Envelope = serde_json::from_str(line).map_err(|failure| {
self.refusal(line_number, &format!("is not an event envelope: {failure}"))
})?;
if envelope.stream != self.token {
return Err(self.refusal(line_number, "carries an event of another stream"));
}
Ok(envelope)
}
fn refusal(&self, line_number: usize, what: &str) -> crate::Error {
error::invalid(format!(
"line {line_number} of the release record for {identity} {what}",
identity = self.identity,
))
}
}
fn supported(session: &SessionToken) -> (BTreeSet<Phase>, Option<String>) {
let every = || (BTreeSet::from(Phase::every()), None);
let (Ok(record), Ok(registry)) = (workspace::load(&session.0), store::load()) else {
return every();
};
let identity = record.identity.clone();
let (Ok(resolution), Ok((rules, source))) = (
store::resolve(®istry, &identity),
policy::load(®istry),
) else {
return every();
};
let mut phases = BTreeSet::from([Phase::Development, Phase::Integrate]);
let resolved = policy::resolve(
&rules,
&source,
&store::normalize(&resolution.identity.origin),
&resolution.publication,
);
if resolved.policy.publication != MergePolicy::LocalDirect {
phases.insert(Phase::Review);
}
let releases_nothing = release::for_repository(®istry, &identity)
.is_ok_and(|located| located.releases.targets.is_empty());
if !releases_nothing {
phases.insert(Phase::Release);
}
(phases, Some(identity))
}
fn landed_at(session: &SessionToken) -> Option<ObjectId> {
let registry = store::load().ok()?;
match status::landing_of(®istry, &session.0).ok()?.landed {
Landed::Yes { evidence } => ObjectId::parse(evidence.commit()),
Landed::No | Landed::Unknown => None,
}
}
fn listed(phases: &BTreeSet<Phase>) -> String {
phases
.iter()
.map(|phase| phase.as_str())
.collect::<Vec<&str>>()
.join(", ")
}
pub fn attributed(line: &str, session: &str, line_number: usize) -> Result<Line> {
let read = Line::read(line).map_err(|e| {
error::invalid(format!(
"line {line_number} of the stream for {session:?} is not an event envelope: {e}"
))
})?;
if read.stream() != session {
return Err(error::invalid(format!(
"line {line_number} of the stream for {session:?} carries an event of stream {:?}",
read.stream()
)));
}
Ok(read)
}
fn recorded(path: &PathBuf) -> u64 {
std::fs::read_to_string(path)
.map(|raw| raw.lines().filter(|line| !line.trim().is_empty()).count() as u64)
.unwrap_or(0)
}
fn stream_identity(id: &str) -> String {
format!("stream:{id}")
}
fn releases_token(identity: &str) -> String {
format!("releases-{}", ids::short_digest(identity))
}
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")))
}