use std::path::PathBuf;
use std::time::Duration;
use crate::contract::schema::Adapter;
use crate::protocol::release::{
BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
};
use super::{
make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, HomebrewFormula, ReleaseAdapter,
SourceTarball,
};
const FORMULA_MARKER_PREFIX: &str = "# Generated by ossctl; do not edit by hand (template-version:";
const FORMULA_TEMPLATE_VERSION: u32 = 1;
fn formula_carries_marker(bytes: &[u8]) -> bool {
let first_line = match bytes.iter().position(|&b| b == b'\n') {
Some(i) => &bytes[..i],
None => bytes,
};
first_line.starts_with(FORMULA_MARKER_PREFIX.as_bytes())
}
pub struct HomebrewAdapter {
adapter: Adapter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FormulaPath {
Create,
TapWrite,
BumpPr,
}
impl HomebrewAdapter {
#[must_use]
pub fn new(adapter: Adapter) -> Self {
debug_assert!(matches!(
adapter,
Adapter::HomebrewTap | Adapter::HomebrewCore
));
Self { adapter }
}
fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
if self.adapter != Adapter::HomebrewTap {
return None;
}
artifacts.and_then(|h| h.tap.as_deref())
}
fn resolve_path(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<FormulaPath, AdapterError> {
let homebrew = ctx.artifacts.homebrew.as_ref();
match self.tap(homebrew) {
Some(tap) => {
if Self::formula_exists(ctx, tap, &t.package)? {
Ok(FormulaPath::TapWrite)
} else {
Ok(FormulaPath::Create)
}
}
None => Ok(FormulaPath::BumpPr),
}
}
fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
let out = ctx
.runner
.run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
.map_err(|e| AdapterError::Io {
command: cmd.rendered(),
source: e.to_string(),
})?;
if out.status == Some(0) {
return Ok(true);
}
if out.stderr.contains("404") || out.stdout.contains("404") {
return Ok(false);
}
let detail = if out.stderr.trim().is_empty() {
out.stdout
} else {
out.stderr
};
Err(AdapterError::Command {
command: cmd.rendered(),
code: out.status,
stderr: detail,
})
}
fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
let mut args: Vec<String> = match self.adapter {
Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
_ => vec!["bump-formula-pr".into()],
};
if let Some(tarball) = tarball {
args.push("--url".into());
args.push(tarball.url.clone());
if let Some(sha256) = &tarball.sha256 {
args.push("--sha256".into());
args.push(sha256.clone());
}
}
args.push("--".into());
args.push(name.to_string());
PlannedCommand {
program: "brew".into(),
args,
}
}
fn fresh_workdir(name: &str, version: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!(
"ossctl-homebrew-{name}-{version}-{}-{nanos}",
std::process::id()
))
}
fn create_branch(name: &str, version: &str) -> String {
format!("ossctl-homebrew-{name}-{version}")
}
fn create_title(name: &str, version: &str) -> String {
format!("{name} {version} (new formula)")
}
fn create_commands(
tap: &str,
name: &str,
version: &str,
workdir: &str,
sha256_present: bool,
) -> Vec<PlannedCommand> {
let branch = Self::create_branch(name, version);
let title = Self::create_title(name, version);
let formula_rel = format!("Formula/{name}.rb");
let body = if sha256_present {
"Automated first-formula bootstrap by ossctl.".to_string()
} else {
"Automated first-formula bootstrap by ossctl.\n\n**Blocked:** the \
`sha256` of the published release tarball is not yet known at cut \
time (the tag archive does not exist until after publish). Fill in \
the `sha256` once the tag is pushed, then mark this PR ready."
.to_string()
};
let mut pr = vec![
"pr".to_string(),
"create".to_string(),
"--repo".to_string(),
tap.to_string(),
"--head".to_string(),
branch.clone(),
"--title".to_string(),
title.clone(),
"--body".to_string(),
body,
];
if !sha256_present {
pr.push("--draft".to_string());
}
vec![
PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
PlannedCommand::new(
"git",
&[
"-C",
workdir,
"-c",
"user.name=ossctl",
"-c",
"user.email=ossctl@users.noreply.github.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
&title,
],
),
PlannedCommand::new(
"git",
&["-C", workdir, "push", "--set-upstream", "origin", &branch],
),
PlannedCommand {
program: "gh".to_string(),
args: pr,
},
]
}
fn run_create(
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
tap: &str,
) -> Result<PublishReceipt, AdapterError> {
validate_package_name(&t.package)?;
let tarball =
ctx.artifacts
.source_tarball
.as_ref()
.ok_or_else(|| AdapterError::Command {
command: "homebrew first-formula".into(),
code: None,
stderr: "cannot generate a first Homebrew formula without a resolvable GitHub \
source-tarball URL (no `origin` GitHub remote?)"
.into(),
})?;
let license = ctx
.artifacts
.homebrew
.as_ref()
.and_then(|h| h.license.as_deref());
let homepage_slug = ctx.artifacts.repo_slug.as_deref();
let formula = render_formula(
&t.package,
homepage_slug,
&tarball.url,
tarball.sha256.as_deref(),
license,
);
let workdir = Self::fresh_workdir(&t.package, &t.version);
let workdir_str = workdir.to_string_lossy().to_string();
let commands = Self::create_commands(
tap,
&t.package,
&t.version,
&workdir_str,
tarball.sha256.is_some(),
);
run_all(ctx, &commands[..1])?;
Self::write_formula(&workdir, &t.package, &formula, WriteMode::CreateNew)?;
let outputs = run_all(ctx, &commands[1..])?;
let remote_url = outputs.last().and_then(|o| {
o.stdout
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with("https://"))
.map(str::to_string)
});
Ok(make_receipt(ctx, t, None, remote_url))
}
fn update_title(name: &str, version: &str) -> String {
format!("{name} {version}")
}
fn update_commands(tap: &str, name: &str, version: &str, workdir: &str) -> Vec<PlannedCommand> {
let title = Self::update_title(name, version);
let formula_rel = format!("Formula/{name}.rb");
vec![
PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
PlannedCommand::new(
"git",
&[
"-C",
workdir,
"-c",
"user.name=ossctl",
"-c",
"user.email=ossctl@users.noreply.github.com",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
&title,
],
),
PlannedCommand::new("git", &["-C", workdir, "push", "origin", "HEAD"]),
]
}
fn run_tap_write(
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
tap: &str,
) -> Result<PublishReceipt, AdapterError> {
validate_package_name(&t.package)?;
let tarball =
ctx.artifacts
.source_tarball
.as_ref()
.ok_or_else(|| AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: "cannot update the Homebrew formula without a resolvable GitHub \
source-tarball URL (no `origin` GitHub remote?)"
.into(),
})?;
let sha256 = tarball
.sha256
.as_deref()
.filter(|s| is_sha256_hex(s))
.ok_or_else(|| AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr:
"refusing to push a Homebrew formula to the tap's default branch without a \
verified sha256 — the digest is absent or not a 64-char hex string (the tag \
archive was not fetched and hashed). A formula on the default branch is what \
`brew install` resolves, so an unverified digest would ship a broken install"
.into(),
})?;
let license = ctx
.artifacts
.homebrew
.as_ref()
.and_then(|h| h.license.as_deref());
let homepage_slug = ctx.artifacts.repo_slug.as_deref();
let workdir = Self::fresh_workdir(&t.package, &t.version);
let workdir_str = workdir.to_string_lossy().to_string();
let commands = Self::update_commands(tap, &t.package, &t.version, &workdir_str);
run_all(ctx, &commands[..1])?;
let formula_path = workdir.join("Formula").join(format!("{}.rb", t.package));
let current = Self::read_existing_formula(&formula_path, &t.package)?;
let updated = if formula_carries_marker(¤t) {
render_formula(
&t.package,
homepage_slug,
&tarball.url,
Some(sha256),
license,
)
} else {
let current_str = std::str::from_utf8(¤t).map_err(|_| AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: "refusing to edit the hand-maintained tap formula: it is not valid \
UTF-8, so a safe surgical `url`/`sha256` edit cannot be applied \
(no ossctl ownership marker present to authorise a full rewrite)"
.into(),
})?;
surgical_url_sha_edit(current_str, &tarball.url, sha256)?
};
let remote_url = Some(format!(
"https://github.com/{tap}/blob/HEAD/Formula/{}.rb",
t.package
));
if current == updated.as_bytes() {
return Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url));
}
Self::write_formula(&workdir, &t.package, &updated, WriteMode::Overwrite)?;
run_all(ctx, &commands[1..])?;
Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url))
}
fn read_existing_formula(path: &std::path::Path, name: &str) -> Result<Vec<u8>, AdapterError> {
let meta = std::fs::symlink_metadata(path).map_err(|e| AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: format!(
"the tap was probed as carrying `{name}.rb` but the cloned checkout does not \
(`{}`: {e}) — refusing to synthesize a formula on the default branch without the \
create-path review gate",
path.display()
),
})?;
if !meta.file_type().is_file() {
return Err(AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: "not a regular file (symlink or directory) — refusing to overwrite".into(),
});
}
std::fs::read(path).map_err(|e| AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: e.to_string(),
})
}
fn write_formula(
workdir: &std::path::Path,
name: &str,
formula: &str,
mode: WriteMode,
) -> Result<(), AdapterError> {
let dir = workdir.join("Formula");
std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
path: dir.to_string_lossy().to_string(),
source: e.to_string(),
})?;
let path = dir.join(format!("{name}.rb"));
let mut opts = std::fs::OpenOptions::new();
opts.write(true);
match mode {
WriteMode::CreateNew => {
opts.create_new(true);
}
WriteMode::Overwrite => {
opts.truncate(true);
}
}
let mut file = opts.open(&path).map_err(|e| AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: e.to_string(),
})?;
std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
AdapterError::Filesystem {
path: path.to_string_lossy().to_string(),
source: e.to_string(),
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteMode {
CreateNew,
Overwrite,
}
impl ReleaseAdapter for HomebrewAdapter {
fn adapter(&self) -> Adapter {
self.adapter
}
fn dry_run(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<DryRunReport, AdapterError> {
let tarball = ctx.artifacts.source_tarball.as_ref();
let path = self.resolve_path(ctx, t)?;
let (planned_commands, mut notes) = match path {
FormulaPath::Create => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.unwrap_or_default();
let workdir = Self::fresh_workdir(&t.package, &t.version);
let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
(
Self::create_commands(
tap,
&t.package,
&t.version,
&workdir.to_string_lossy(),
sha256_present,
),
vec![format!(
"create path: `{}` has no `{}.rb` yet — generating the initial \
source-build formula and opening a{} PR",
tap,
t.package,
if sha256_present { "" } else { " draft" }
)],
)
}
FormulaPath::TapWrite => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.unwrap_or_default();
let workdir = Self::fresh_workdir(&t.package, &t.version);
let mut notes = vec![format!(
"tap-write path: `{}` already serves `{}.rb` — rendering the updated \
formula and pushing it directly to the tap's default branch (no \
`brew`, no PR)",
tap, t.package,
)];
if tarball.and_then(|tb| tb.sha256.as_deref()).is_none() {
notes.push(
"publish will require a verified post-tag sha256 (absent in this \
pre-tag preview); the coordinator supplies it after the tag is pushed"
.to_string(),
);
}
(
Self::update_commands(tap, &t.package, &t.version, &workdir.to_string_lossy()),
notes,
)
}
FormulaPath::BumpPr => (
vec![self.bump_command(tarball, &t.package)],
vec![
"bump-PR path: no configured tap — `brew bump-formula-pr` opens a reviewed PR"
.to_string(),
],
),
};
match tarball {
Some(tb) => {
let sha = tb.sha256.as_deref().unwrap_or({
if path == FormulaPath::BumpPr {
"(computed by brew from --url)"
} else {
"(resolved and verified by the coordinator post-tag)"
}
});
notes.push(format!("url: {} ; sha256: {sha}", tb.url));
}
None => notes
.push("source tarball url is resolved by the coordinator at cut time".to_string()),
}
Ok(DryRunReport {
adapter: self.adapter,
planned_commands,
notes,
})
}
fn build(
&self,
_ctx: &EffectCtx<'_>,
_t: &AdapterTarget,
) -> Result<BuildArtifacts, AdapterError> {
Ok(BuildArtifacts {
adapter: self.adapter,
artifacts: vec![],
notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
})
}
fn publish(
&self,
ctx: &EffectCtx<'_>,
t: &AdapterTarget,
) -> Result<PublishReceipt, AdapterError> {
match self.resolve_path(ctx, t)? {
FormulaPath::Create => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.expect("resolve_path returns Create only when a tap is configured");
Self::run_create(ctx, t, tap)
}
FormulaPath::TapWrite => {
let tap = self
.tap(ctx.artifacts.homebrew.as_ref())
.expect("resolve_path returns TapWrite only when a tap is configured");
Self::run_tap_write(ctx, t, tap)
}
FormulaPath::BumpPr => {
let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
run_all(ctx, &[cmd])?;
Ok(make_receipt(ctx, t, None, None))
}
}
}
fn verify(
&self,
_ctx: &EffectCtx<'_>,
_receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
Ok(VerifyOutcome::Unknown)
}
fn timeout(&self) -> Duration {
Duration::from_secs(600)
}
}
pub(super) fn render_formula(
name: &str,
homepage_slug: Option<&str>,
url: &str,
sha256: Option<&str>,
license: Option<&str>,
) -> String {
let class = formula_class(name);
let name_lit = ruby_escape(name);
let homepage = homepage_slug.map_or_else(
|| ruby_escape(url),
|s| ruby_escape(&format!("https://github.com/{s}")),
);
let url_lit = ruby_escape(url);
let sha_line = match sha256 {
Some(sha) => format!(" sha256 \"{}\"", ruby_escape(sha)),
None => " # TODO: sha256 of the published release tarball \
(unavailable at cut time — fill in after the tag archive exists)"
.to_string(),
};
let license_line = license
.map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
.unwrap_or_default();
let marker = format!("{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})");
format!(
"{marker}\n\
class {class} < Formula\n\
\x20 desc \"{name_lit}\"\n\
\x20 homepage \"{homepage}\"\n\
\x20 url \"{url_lit}\"\n\
{sha_line}\n\
{license_line}\
\n\
\x20 depends_on \"rust\" => :build\n\
\n\
\x20 def install\n\
\x20 system \"cargo\", \"install\", *std_cargo_args\n\
\x20 end\n\
\n\
\x20 test do\n\
\x20 system bin/\"{name_lit}\", \"--version\"\n\
\x20 end\n\
end\n"
)
}
fn ruby_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('#', "\\#")
}
fn surgical_url_sha_edit(current: &str, url: &str, sha256: &str) -> Result<String, AdapterError> {
fn is_stanza_line(line: &str, keyword: &str) -> bool {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix(keyword) else {
return false;
};
rest.starts_with(|c: char| c.is_ascii_whitespace()) && rest.trim_start().starts_with('"')
}
fn rewrite_quoted_value(line: &str, new_value: &str) -> Option<String> {
let open = line.find('"')?;
let after = &line[open + 1..];
let bytes = after.as_bytes();
let mut i = 0;
let close = loop {
match bytes.get(i)? {
b'\\' => i += 2, b'"' => break i,
_ => i += 1,
}
};
let prefix = &line[..open];
let suffix = &after[close + 1..];
Some(format!("{prefix}\"{}\"{suffix}", ruby_escape(new_value)))
}
let mut url_hits = 0usize;
let mut sha_hits = 0usize;
let mut malformed = false;
let mut rebuilt = String::with_capacity(current.len() + 64);
for (idx, line) in current.split('\n').enumerate() {
if idx > 0 {
rebuilt.push('\n');
}
let (hits, new_value) = if is_stanza_line(line, "url") {
(Some(&mut url_hits), url)
} else if is_stanza_line(line, "sha256") {
(Some(&mut sha_hits), sha256)
} else {
(None, "")
};
if let Some(counter) = hits {
*counter += 1;
if let Some(rewritten) = rewrite_quoted_value(line, new_value) {
rebuilt.push_str(&rewritten);
} else {
malformed = true;
rebuilt.push_str(line);
}
} else {
rebuilt.push_str(line);
}
}
if url_hits != 1 || sha_hits != 1 || malformed {
return Err(AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: format!(
"refusing to update the hand-maintained tap formula: it carries no ossctl \
ownership marker, and a safe surgical `url`/`sha256` edit needs exactly one \
canonical `url \"…\"` line and one `sha256 \"…\"` line with a properly closed \
literal, but found {url_hits} `url` and {sha_hits} `sha256`{} — update the \
formula by hand, or add the ossctl marker \
(`{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})`) as the first line to \
opt into full regeneration",
if malformed {
" (a matched stanza's quoted value was not closed on its line)"
} else {
""
}
),
});
}
Ok(rebuilt)
}
fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn validate_package_name(name: &str) -> Result<(), AdapterError> {
let bad = name.is_empty()
|| name.starts_with('.')
|| name.contains('/')
|| name.contains('\\')
|| name.split(['/', '\\']).any(|seg| seg == "..");
if bad {
return Err(AdapterError::Filesystem {
path: name.to_string(),
source: "invalid Homebrew package name — must not be empty, start with `.`, or \
contain a path separator or `..` traversal component"
.into(),
});
}
Ok(())
}
fn formula_class(name: &str) -> String {
let mut out = String::new();
for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
let mut chars = segment.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
if out.is_empty() {
return "Formula".to_string();
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, 'X');
}
out
}