use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::core::attribution::ATTRIBUTION_FOOTER;
pub const SESSION_REF_ROOT: &str = "refs/tm/sessions";
const BLOB_MODE: &str = "100644";
#[derive(Debug, thiserror::Error)]
pub enum RefPublishError {
#[error(
"no user id resolved for the session ref: `gh` names no active github.com \
account and `git config user.name` is unset or unusable as a ref component"
)]
NoUserId,
#[error("session id {0:?} cannot be used as a git ref component")]
NoSessionKey(String),
#[error(
"the pause snapshot carries credential-shaped content ({0}); the session ref \
was not published and the snapshot stays in the local cache only"
)]
CredentialDetected(String),
#[error(
"{ref_name} moved on origin since this pause read its tip ({expected}); the \
lease-checked push was rejected and is NEVER retried with --force"
)]
StaleLease {
ref_name: String,
expected: String,
},
#[error("{0} is not a git checkout with an `origin` remote")]
NoOrigin(String),
#[error("git {step} failed: {message}")]
Git {
step: &'static str,
message: String,
},
#[error("session ref publish io error: {0}")]
Io(#[from] std::io::Error),
#[error("snapshot {0} is not inside the project's .trusty-mpm/sessions/ store")]
SnapshotOutsideStore(String),
}
#[derive(Debug, Clone)]
pub struct SessionRefRequest<'a> {
pub repo: &'a Path,
pub session_id: &'a str,
pub snapshot_path: &'a Path,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionRefOutcome {
pub ref_name: String,
pub commit: String,
pub parent: Option<String>,
}
pub fn sanitize_ref_component(raw: &str) -> Option<String> {
let filtered: String = raw
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
.collect();
if filtered.is_empty()
|| filtered == "."
|| filtered == ".."
|| filtered.contains("..")
|| filtered.starts_with('.')
|| filtered.starts_with('-')
|| filtered.ends_with('.')
|| filtered.ends_with(".lock")
{
return None;
}
Some(filtered)
}
pub fn resolve_user_id(repo: &Path) -> Option<String> {
if let Some(login) = crate::core::gh_account::gh_account_status_local()
.and_then(|status| status.active)
.filter(|login| !login.contains('@'))
.and_then(|login| sanitize_ref_component(&login))
{
return Some(login);
}
let out = trusty_common::git::command_in(repo)
.args(["config", "user.name"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.contains('@') {
return None;
}
sanitize_ref_component(&name)
}
pub fn session_key(session_id: &str) -> Option<String> {
use trusty_common::catchup::session_id::TMUX_WINDOW_ID_PREFIX;
let base = sanitize_ref_component(session_id)?;
if !session_id.starts_with(TMUX_WINDOW_ID_PREFIX) {
return Some(base);
}
match sysinfo::System::host_name()
.as_deref()
.and_then(sanitize_ref_component)
{
Some(host) => sanitize_ref_component(&format!("{host}-{base}")),
None => Some(base),
}
}
pub fn session_ref_for(repo: &Path, session_id: &str) -> Option<String> {
let user = resolve_user_id(repo)?;
let key = session_key(session_id)?;
Some(format!("{SESSION_REF_ROOT}/{user}/{key}"))
}
const CREDENTIAL_PREFIXES: &[(&str, usize)] = &[
("ghp_", 16),
("gho_", 16),
("ghu_", 16),
("ghs_", 16),
("ghr_", 16),
("github_pat_", 16),
("glpat-", 16),
("sk-ant-", 16),
("sk-proj-", 16),
("sk-or-v1-", 16),
("xoxb-", 16),
("xoxp-", 16),
("xoxa-", 16),
("xoxs-", 16),
("AKIA", 16),
("ASIA", 16),
("AIza", 16),
("npm_", 16),
];
const CREDENTIAL_LITERALS: &[&str] = &[
"-----BEGIN OPENSSH PRIVATE KEY",
"-----BEGIN RSA PRIVATE KEY",
"-----BEGIN PRIVATE KEY",
"-----BEGIN EC PRIVATE KEY",
];
pub fn scan_for_credentials(content: &str) -> Option<String> {
for literal in CREDENTIAL_LITERALS {
if content.contains(literal) {
return Some((*literal).to_string());
}
}
for (prefix, min_tail) in CREDENTIAL_PREFIXES {
let mut rest = content;
while let Some(at) = rest.find(prefix) {
let tail = &rest[at + prefix.len()..];
let run = tail
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
.count();
if run >= *min_tail {
return Some(format!("{prefix}…"));
}
rest = &rest[at + prefix.len()..];
}
}
None
}
fn git(
repo: &Path,
args: &[&str],
index_file: Option<&Path>,
env: &[(&str, String)],
step: &'static str,
) -> Result<String, RefPublishError> {
let mut cmd = trusty_common::git::command_in(repo);
cmd.args(args);
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
if let Some(idx) = index_file {
cmd.env("GIT_INDEX_FILE", idx);
}
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output()?;
if !out.status.success() {
return Err(RefPublishError::Git {
step,
message: failure_text(&out),
});
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn failure_text(out: &std::process::Output) -> String {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
if err.is_empty() {
String::from_utf8_lossy(&out.stdout).trim().to_string()
} else {
err
}
}
pub fn remote_tip(repo: &Path, ref_name: &str) -> Result<Option<String>, RefPublishError> {
let out = git(
repo,
&["ls-remote", "origin", ref_name],
None,
&[],
"ls-remote",
)?;
Ok(out
.lines()
.next()
.and_then(|line| line.split_whitespace().next())
.map(str::to_string))
}
pub fn push_session_ref(
repo: &Path,
ref_name: &str,
expected_tip: Option<&str>,
) -> Result<(), RefPublishError> {
let lease = format!(
"--force-with-lease={ref_name}:{}",
expected_tip.unwrap_or("")
);
let refspec = format!("{ref_name}:{ref_name}");
match git(
repo,
&["push", &lease, "origin", &refspec],
None,
&[],
"push",
) {
Ok(_) => Ok(()),
Err(RefPublishError::Git {
step: "push",
message,
}) if is_lease_rejection(&message) => Err(RefPublishError::StaleLease {
ref_name: ref_name.to_string(),
expected: expected_tip.unwrap_or("<absent>").to_string(),
}),
Err(e) => Err(e),
}
}
fn is_lease_rejection(message: &str) -> bool {
message.contains("stale info")
|| message.contains("non-fast-forward")
|| message.contains("[rejected]")
}
pub fn publish_session_ref(
req: &SessionRefRequest<'_>,
) -> Result<SessionRefOutcome, RefPublishError> {
publish_session_ref_as(req, resolve_user_id(req.repo).as_deref())
}
pub fn publish_session_ref_as(
req: &SessionRefRequest<'_>,
user_id: Option<&str>,
) -> Result<SessionRefOutcome, RefPublishError> {
let snapshot = std::fs::read_to_string(req.snapshot_path)?;
if let Some(hit) = scan_for_credentials(&snapshot) {
return Err(RefPublishError::CredentialDetected(hit));
}
let rel = store_relative(req.repo, req.snapshot_path)?;
let tree_path = format!(
"{}{rel}",
trusty_common::catchup::session_refs::SESSIONS_STORE_PREFIX
);
let user = user_id
.and_then(sanitize_ref_component)
.ok_or(RefPublishError::NoUserId)?;
let key = session_key(req.session_id)
.ok_or_else(|| RefPublishError::NoSessionKey(req.session_id.to_string()))?;
let ref_name = format!("{SESSION_REF_ROOT}/{user}/{key}");
git(
req.repo,
&["remote", "get-url", "origin"],
None,
&[],
"remote",
)
.map_err(|_| RefPublishError::NoOrigin(req.repo.display().to_string()))?;
if let Err(e) = trusty_common::catchup::session_refs::ensure_fetch_refspec(req.repo) {
tracing::warn!(repo = %req.repo.display(), "could not register the session fetch refspec: {e}");
}
let parent = remote_tip(req.repo, &ref_name)?;
if parent.is_some() {
let refspec = format!("+{ref_name}:{ref_name}");
git(req.repo, &["fetch", "origin", &refspec], None, &[], "fetch")?;
}
let blob = git(
req.repo,
&[
"hash-object",
"-w",
"--no-filters",
"--",
&req.snapshot_path.to_string_lossy(),
],
None,
&[],
"hash-object",
)?;
let scratch = tempfile::TempDir::new()?;
let index = scratch.path().join("index");
let idx = Some(index.as_path());
git(req.repo, &["read-tree", "--empty"], idx, &[], "read-tree")?;
let cacheinfo = format!("{BLOB_MODE},{blob},{tree_path}");
git(
req.repo,
&["update-index", "--add", "--cacheinfo", &cacheinfo],
idx,
&[],
"update-index",
)?;
let tree = git(req.repo, &["write-tree"], idx, &[], "write-tree")?;
let message = commit_message(req, &rel);
let mut args: Vec<&str> = vec!["commit-tree", "--no-gpg-sign", &tree];
if let Some(p) = &parent {
args.push("-p");
args.push(p);
}
args.push("-m");
args.push(&message);
let commit = git(
req.repo,
&args,
None,
&commit_identity(&user, req.timestamp),
"commit-tree",
)?;
git(
req.repo,
&["update-ref", &ref_name, &commit],
None,
&[],
"update-ref",
)?;
push_session_ref(req.repo, &ref_name, parent.as_deref())?;
Ok(SessionRefOutcome {
ref_name,
commit,
parent,
})
}
fn store_relative(repo: &Path, snapshot: &Path) -> Result<String, RefPublishError> {
let store = sessions_store(repo);
snapshot
.strip_prefix(&store)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.map_err(|_| RefPublishError::SnapshotOutsideStore(snapshot.display().to_string()))
}
fn commit_message(req: &SessionRefRequest<'_>, rel: &str) -> String {
let ts = req.timestamp.to_rfc3339();
format!(
"pause {session} {ts}\n\n\
Session-Id: {session}\n\
Session-Event: pause\n\
Session-Snapshot: {rel}\n\
Session-Timestamp: {ts}\n\n\
{ATTRIBUTION_FOOTER}\n",
session = req.session_id,
)
}
fn commit_identity(user: &str, timestamp: DateTime<Utc>) -> Vec<(&'static str, String)> {
let email = format!("{user}@users.noreply.github.com");
let date = timestamp.to_rfc3339();
vec![
("GIT_AUTHOR_NAME", user.to_string()),
("GIT_AUTHOR_EMAIL", email.clone()),
("GIT_AUTHOR_DATE", date.clone()),
("GIT_COMMITTER_NAME", user.to_string()),
("GIT_COMMITTER_EMAIL", email),
("GIT_COMMITTER_DATE", date),
]
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionRefReceipt {
pub ref_name: Option<String>,
pub published: bool,
pub error: Option<String>,
}
pub fn publish_receipt(req: &SessionRefRequest<'_>, enabled: bool) -> SessionRefReceipt {
if !enabled {
return SessionRefReceipt::default();
}
let user_id = resolve_user_id(req.repo);
let ref_name = user_id
.as_deref()
.zip(session_key(req.session_id))
.map(|(user, key)| format!("{SESSION_REF_ROOT}/{user}/{key}"));
match publish_session_ref_as(req, user_id.as_deref()) {
Ok(out) => SessionRefReceipt {
ref_name: Some(out.ref_name),
published: true,
error: None,
},
Err(e) => {
let message = e.to_string();
tracing::warn!(
project = %req.repo.display(),
session_ref = ref_name.as_deref().unwrap_or("<unresolved>"),
"session ref publish failed: {message}"
);
SessionRefReceipt {
ref_name,
published: false,
error: Some(message),
}
}
}
}
pub fn sessions_store(repo: &Path) -> PathBuf {
repo.join(".trusty-mpm").join("sessions")
}
#[cfg(test)]
#[path = "session_ref_publish_tests.rs"]
mod tests;