use std::fmt::Write as _;
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, HomebrewAsset, HomebrewFormula,
ReleaseAdapter, SourceTarball,
};
const FORMULA_MARKER_PREFIX: &str =
"# Generated by shipshape; do not edit by hand (template-version:";
const LEGACY_FORMULA_MARKER_PREFIX: &str =
"# Generated by ossctl; do not edit by hand (template-version:";
const FORMULA_TEMPLATE_VERSION: u32 = 2;
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())
|| first_line.starts_with(LEGACY_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!(
"shipshape-homebrew-{name}-{version}-{}-{nanos}",
std::process::id()
))
}
fn create_branch(name: &str, version: &str) -> String {
format!("shipshape-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 shipshape.".to_string()
} else {
"Automated first-formula bootstrap by shipshape.\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=shipshape",
"-c",
"user.email=shipshape@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 homebrew = ctx.artifacts.homebrew.as_ref();
let assets = verified_assets(ctx)?;
let license = homebrew.and_then(|h| h.license.as_deref());
let description = homebrew.and_then(|h| h.description.as_deref());
let homepage_slug = ctx.artifacts.repo_slug.as_deref();
let formula = render_formula(
&t.package,
&t.version,
homepage_slug,
description,
assets,
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, true);
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=shipshape",
"-c",
"user.email=shipshape@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 homebrew = ctx.artifacts.homebrew.as_ref();
let assets = verified_assets(ctx)?;
let license = homebrew.and_then(|h| h.license.as_deref());
let description = homebrew.and_then(|h| h.description.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,
&t.version,
homepage_slug,
description,
assets,
license,
)
} else {
return Err(AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: format!(
"refusing to replace an unmarked source-build or hand-maintained formula with a generated prebuilt formula; to explicitly authorize Shipshape ownership, make the file's first line exactly `# Generated by shipshape; do not edit by hand (template-version: {FORMULA_TEMPLATE_VERSION})`"
),
});
};
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, None, remote_url));
}
Self::write_formula(&workdir, &t.package, &updated, WriteMode::Overwrite)?;
run_all(ctx, &commands[1..])?;
Ok(make_receipt(ctx, t, None, 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);
let outputs = run_all(ctx, &[cmd])?;
let remote_url = outputs.last().and_then(|output| {
output
.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 verify(
&self,
ctx: &EffectCtx<'_>,
receipt: &PublishReceipt,
) -> Result<VerifyOutcome, AdapterError> {
let Some(url) = receipt.remote_url.as_deref() else {
return Ok(VerifyOutcome::Unknown);
};
let parts: Vec<_> = url.split('/').collect();
let Some(github) = parts.iter().position(|part| *part == "github.com") else {
return Ok(VerifyOutcome::Unknown);
};
let (Some(owner), Some(repo)) = (parts.get(github + 1), parts.get(github + 2)) else {
return Ok(VerifyOutcome::Unknown);
};
Ok(verify_tap_formula(
ctx,
&format!("{owner}/{repo}"),
&receipt.package,
&receipt.version,
self.adapter == Adapter::HomebrewTap,
None,
))
}
fn timeout(&self) -> Duration {
Duration::from_secs(600)
}
}
pub(crate) fn verify_tap_formula(
ctx: &EffectCtx<'_>,
tap: &str,
package: &str,
version: &str,
require_marker: bool,
expected_platforms: Option<&[String]>,
) -> VerifyOutcome {
let raw = format!(
"https://raw.githubusercontent.com/{tap}/HEAD/Formula/{package}.rb?shipshape_verify={}",
ctx.clock.now_unix()
);
let (status, body) = match ctx.registry.http_get(&raw) {
Ok(response) => response,
Err(_) => return VerifyOutcome::Unknown,
};
if status == 404 {
return VerifyOutcome::Missing;
}
if status != 200 {
return VerifyOutcome::Unknown;
}
let formula = match String::from_utf8(body) {
Ok(formula) => formula,
Err(_) => return VerifyOutcome::Unknown,
};
if require_marker && !formula_carries_marker(formula.as_bytes()) {
return VerifyOutcome::Missing;
}
if !formula
.lines()
.any(|line| line.trim() == format!("version \"{version}\""))
{
return VerifyOutcome::Missing;
}
let expected = expected_platforms
.filter(|platforms| !platforms.is_empty())
.or_else(|| {
ctx.artifacts
.homebrew
.as_ref()
.map(|homebrew| homebrew.platforms.as_slice())
});
let observed = [
"if OS.mac? && Hardware::CPU.arm?",
"if OS.mac? && Hardware::CPU.intel?",
"if OS.linux? && Hardware::CPU.arm?",
"if OS.linux? && Hardware::CPU.intel?",
];
let conditions: Vec<&str> = match expected {
Some(platforms) => platforms
.iter()
.filter_map(|triple| homebrew_platform_condition(triple))
.collect(),
None => observed
.into_iter()
.filter(|condition| formula.contains(condition))
.collect(),
};
if conditions.is_empty() {
return VerifyOutcome::Missing;
}
for condition in conditions {
if !formula_has_platform_stanza(&formula, condition) {
return VerifyOutcome::Missing;
}
}
VerifyOutcome::Matches
}
fn formula_has_platform_stanza(formula: &str, condition: &str) -> bool {
fn block_for_condition<'a>(lines: &'a [&str], condition: &str) -> Option<&'a [&'a str]> {
let start = lines.iter().position(|line| line.trim() == condition)?;
let indent = lines[start].len() - lines[start].trim_start().len();
let end = lines[start + 1..].iter().position(|line| {
line.trim() == "end" && line.len() - line.trim_start().len() == indent
})?;
Some(&lines[start + 1..start + 1 + end])
}
fn has_url_and_sha(lines: &[&str]) -> bool {
lines
.iter()
.any(|line| line.trim_start().starts_with("url \""))
&& lines
.iter()
.any(|line| line.trim_start().starts_with("sha256 \""))
}
let (os, cpu) = match condition {
"if OS.mac? && Hardware::CPU.arm?" => ("if OS.mac?", "if Hardware::CPU.arm?"),
"if OS.mac? && Hardware::CPU.intel?" => ("if OS.mac?", "if Hardware::CPU.intel?"),
"if OS.linux? && Hardware::CPU.arm?" => ("if OS.linux?", "if Hardware::CPU.arm?"),
"if OS.linux? && Hardware::CPU.intel?" => ("if OS.linux?", "if Hardware::CPU.intel?"),
_ => return false,
};
let lines: Vec<&str> = formula.lines().collect();
if let Some(os_block) = block_for_condition(&lines, os) {
if let Some(cpu_block) = block_for_condition(os_block, cpu) {
if has_url_and_sha(cpu_block) {
return true;
}
}
}
block_for_condition(&lines, condition).is_some_and(has_url_and_sha)
}
pub(super) fn render_formula(
name: &str,
version: &str,
homepage_slug: Option<&str>,
description: Option<&str>,
assets: &[HomebrewAsset],
license: Option<&str>,
) -> String {
let class = formula_class(name);
let name_lit = ruby_escape(name);
let homepage = homepage_slug.map_or_else(
|| "https://github.com".to_string(),
|s| format!("https://github.com/{s}"),
);
let desc = ruby_escape(description.unwrap_or(name));
let mut platforms = String::new();
for asset in assets {
let Some(condition) = homebrew_platform_condition(&asset.triple) else {
continue;
};
let _ = write!(
platforms,
" {condition}\n url \"{}\"\n sha256 \"{}\"\n end\n",
ruby_escape(&asset.url),
ruby_escape(&asset.sha256)
);
}
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}\nclass {class} < Formula\n desc \"{desc}\"\n homepage \"{}\"\n version \"{}\"\n{platforms}{license_line}\n def install\n bin.install \"{name_lit}\"\n end\n\n test do\n system bin/\"{name_lit}\", \"version\"\n end\nend\n", ruby_escape(&homepage), ruby_escape(version))
}
pub(crate) fn homebrew_platform_condition(triple: &str) -> Option<&'static str> {
match triple {
"aarch64-apple-darwin" => Some("if OS.mac? && Hardware::CPU.arm?"),
"x86_64-apple-darwin" => Some("if OS.mac? && Hardware::CPU.intel?"),
"aarch64-unknown-linux-musl" => Some("if OS.linux? && Hardware::CPU.arm?"),
"x86_64-unknown-linux-musl" => Some("if OS.linux? && Hardware::CPU.intel?"),
_ => None,
}
}
fn verified_assets<'a>(ctx: &'a EffectCtx<'a>) -> Result<&'a [HomebrewAsset], AdapterError> {
let assets = &ctx.artifacts.homebrew_assets;
if assets.is_empty()
|| assets
.iter()
.any(|a| !is_sha256_hex(&a.sha256) || a.url.is_empty())
{
return Err(AdapterError::Command {
command: "homebrew formula update".into(),
code: None,
stderr: "refusing to write a Homebrew formula without verified prebuilt release assets and SHA-256 checksums".into(),
});
}
Ok(assets)
}
fn ruby_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('#', "\\#")
}
#[allow(dead_code)]
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 shipshape \
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 shipshape 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
}
#[cfg(test)]
mod platform_stanza_tests {
use super::{formula_carries_marker, formula_has_platform_stanza};
#[test]
fn current_and_legacy_ownership_markers_are_both_trusted() {
assert!(formula_carries_marker(
b"# Generated by shipshape; do not edit by hand (template-version: 2)\nclass Tool"
));
assert!(formula_carries_marker(
b"# Generated by ossctl; do not edit by hand (template-version: 2)\nclass Tool"
));
assert!(!formula_carries_marker(
b"# hand maintained\n# Generated by ossctl; do not edit by hand (template-version: 2)"
));
}
const CARGO_DIST_0_28_2_FORMULA: &str =
include_str!("../fixtures/project-canon-cargo-dist-0.28.2.rb");
#[test]
fn cargo_dist_nested_downloads_are_not_shadowed_by_install_guards() {
for condition in [
"if OS.mac? && Hardware::CPU.arm?",
"if OS.linux? && Hardware::CPU.arm?",
"if OS.linux? && Hardware::CPU.intel?",
] {
assert!(
formula_has_platform_stanza(CARGO_DIST_0_28_2_FORMULA, condition),
"cargo-dist 0.28.2 formula should contain {condition}"
);
}
}
#[test]
fn a_matching_install_guard_without_a_download_stanza_is_not_enough() {
let formula = r#"class Tool < Formula
version "1.0.0"
def install
if OS.linux? && Hardware::CPU.arm?
bin.install "tool"
end
end
end
"#;
assert!(!formula_has_platform_stanza(
formula,
"if OS.linux? && Hardware::CPU.arm?"
));
}
}