ossctl_core/release/adapters/homebrew.rs
1//! Homebrew distribution adapter: `homebrew-tap` and `homebrew-core`.
2//!
3//! Updates a Homebrew formula (a custom tap, or a `homebrew-core` bump PR) so
4//! `brew install` resolves the new version. A tap/core is not observable through
5//! the [`RegistryQuery`](crate::ports::RegistryQuery) port, so `verify` returns
6//! [`VerifyOutcome::Unknown`] **explicitly** rather than being excused from the
7//! contract (ADR-0002 §1) — an honest "cannot check", never a false `Missing`.
8//!
9//! ## Three formula paths: create, tap-write, bump-PR
10//!
11//! A release either **creates** the first `<name>.rb` on an empty tap or
12//! **updates** an existing one. This adapter chooses by asking the tap whether the
13//! formula already exists (through the injected
14//! [`CommandRunner`](crate::ports::CommandRunner), so it is testable with no real
15//! network or tap):
16//!
17//! - **configured tap, formula absent** → the *create* path: generate a
18//! source-build formula (the release tarball's `url` + `sha256`, the license, a
19//! cargo build/install stanza), clone the tap, commit the new file on a branch,
20//! and open a PR.
21//! - **configured tap, formula present** → the *tap-write* path
22//! (`FormulaPath::TapWrite`): render the updated `<name>.rb` from the verified
23//! `url` + `sha256`, clone the tap, update the file, commit, and **push
24//! directly to the tap's default branch** — no `brew`, no `bump-formula-pr`, no
25//! `brew audit`. This is deterministic, needs no local `brew`/ruby/gem toolchain
26//! on the cutting machine, and mirrors the manual fallback that has always
27//! worked. It **fails closed** without a verified `sha256` (unlike create, which
28//! can open a draft-PR placeholder, a formula on the tap's *default branch* is
29//! what `brew install` resolves — so an unverified digest would ship a broken
30//! install), and it is a **clean no-op** when the tap already carries exactly
31//! this formula (an idempotent resume/re-run at the target version).
32//! - **no configured tap** (a `homebrew-core` target, or a `homebrew-tap` with no
33//! resolved tap) → the *bump-PR* path: `brew bump-formula-pr` carrying the
34//! release tarball's `--url` (+ `--sha256` when a digest is available). First
35//! submission to `homebrew-core` is a human review process where the full core
36//! `brew audit` is appropriate, so the PR path is kept for it.
37//!
38//! ## Why tap-write replaced `brew bump-formula-pr` for the tap-bump case
39//!
40//! `brew bump-formula-pr` runs a full `brew audit` internally and aborts the whole
41//! bump on any finding — including cosmetic core-lint changes irrelevant to a
42//! personal tap the maintainer controls (issue `homebrew-dist-brew-audit-fails`:
43//! the first engine dogfood cut failed here with a swallowed audit message). The
44//! tap-write path removes that dependency entirely and surfaces any real git/`gh`
45//! failure verbatim through the shared `run_all` runner rather than as a black box.
46
47use std::path::PathBuf;
48use std::time::Duration;
49
50use crate::contract::schema::Adapter;
51use crate::protocol::release::{
52 BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
53};
54
55use super::{
56 make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, HomebrewFormula, ReleaseAdapter,
57 SourceTarball,
58};
59
60/// The stable, greppable prefix of the **ownership marker** every ossctl-generated
61/// formula carries as its first line. Its presence is how the *tap-write* path tells
62/// an ossctl-managed formula (safe to fully regenerate) from a hand-maintained one
63/// (never clobber — surgically edit `url`/`sha256` only, or refuse). The prefix
64/// deliberately omits the version integer so a future `template-version` bump still
65/// reads as "ossctl-managed". See [`render_formula`] (writer) and
66/// [`formula_carries_marker`] (reader).
67const FORMULA_MARKER_PREFIX: &str = "# Generated by ossctl; do not edit by hand (template-version:";
68
69/// The current formula-template version, embedded in the ownership marker. Bump this
70/// when the generated formula's *shape* changes in a way worth recording; the marker
71/// still identifies the file as ossctl-managed regardless of the integer.
72const FORMULA_TEMPLATE_VERSION: u32 = 1;
73
74/// Whether `bytes` carry the ossctl ownership marker on their **first line** —
75/// exactly where [`render_formula`] writes it. The check is deliberately anchored to
76/// the first line (not a search anywhere in the file): a hand-maintained formula that
77/// merely *quotes* the marker string in a comment, `desc`, `caveats`, or embedded
78/// `__END__`/patch data must NOT be mistaken for ossctl output and fully regenerated
79/// (that would clobber the hand-authored stanzas this whole path exists to protect).
80/// Operates on bytes so a non-UTF-8 formula needs no lossy conversion; a trailing
81/// `\r` (CRLF) after the marker is irrelevant to the `starts_with` prefix test. A
82/// marked formula is ossctl-managed → safe to fully regenerate; an unmarked one is
83/// hand-maintained → the tap-write path must not clobber it.
84fn formula_carries_marker(bytes: &[u8]) -> bool {
85 let first_line = match bytes.iter().position(|&b| b == b'\n') {
86 Some(i) => &bytes[..i],
87 None => bytes,
88 };
89 first_line.starts_with(FORMULA_MARKER_PREFIX.as_bytes())
90}
91
92/// The homebrew distribution adapter, operating as `homebrew-tap` or
93/// `homebrew-core`.
94pub struct HomebrewAdapter {
95 adapter: Adapter,
96}
97
98/// Which formula operation the adapter resolved for a target.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum FormulaPath {
101 /// Generate + PR the initial `<name>.rb` (configured tap, no formula yet).
102 Create,
103 /// Render + write the updated `<name>.rb` directly to the configured tap's
104 /// default branch (configured tap, formula already present) — no `brew`, no
105 /// PR. See the module docs' *tap-write* path.
106 TapWrite,
107 /// `brew bump-formula-pr` an already-present formula — the fallback for a
108 /// target with no configured tap (`homebrew-core`, or an unconfigured
109 /// `homebrew-tap`), where a reviewed PR + full `brew audit` is the right path.
110 BumpPr,
111}
112
113impl HomebrewAdapter {
114 /// Construct for a resolved homebrew adapter identity.
115 #[must_use]
116 pub fn new(adapter: Adapter) -> Self {
117 debug_assert!(matches!(
118 adapter,
119 Adapter::HomebrewTap | Adapter::HomebrewCore
120 ));
121 Self { adapter }
122 }
123
124 /// The destination tap slug (`owner/repo`) for a `homebrew-tap` create, when
125 /// the contract configured one. `None` for `homebrew-core` or an unconfigured
126 /// tap — which pins the adapter to the bump path (no bootstrap destination).
127 fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
128 if self.adapter != Adapter::HomebrewTap {
129 return None;
130 }
131 artifacts.and_then(|h| h.tap.as_deref())
132 }
133
134 /// Decide the formula path for `target`. A `homebrew-tap` with a configured
135 /// tap probes the tap for the formula: absent → [`FormulaPath::Create`],
136 /// present → [`FormulaPath::TapWrite`] (direct write to the tap). Every other
137 /// case (no configured tap: `homebrew-core`, or an unconfigured tap) is
138 /// [`FormulaPath::BumpPr`].
139 ///
140 /// The probe runs through the injected runner (`gh api …/contents/…`); a
141 /// non-zero exit (a `404`, typically) reads as *absent* → create. A spawn
142 /// failure is a real error and is propagated.
143 fn resolve_path(
144 &self,
145 ctx: &EffectCtx<'_>,
146 t: &AdapterTarget,
147 ) -> Result<FormulaPath, AdapterError> {
148 let homebrew = ctx.artifacts.homebrew.as_ref();
149 match self.tap(homebrew) {
150 Some(tap) => {
151 if Self::formula_exists(ctx, tap, &t.package)? {
152 Ok(FormulaPath::TapWrite)
153 } else {
154 Ok(FormulaPath::Create)
155 }
156 }
157 None => Ok(FormulaPath::BumpPr),
158 }
159 }
160
161 /// Ask the tap whether `Formula/<name>.rb` already exists, through the runner.
162 ///
163 /// Uses `gh api` against the tap's contents endpoint so no local checkout is
164 /// needed for the probe. Only three outcomes are safe to act on:
165 ///
166 /// - exit `0` (the file is served) ⇒ **present** → bump.
167 /// - a genuine `404` (`gh` renders it as `Not Found (HTTP 404)`) ⇒ **absent**
168 /// → create.
169 /// - **anything else** — auth failure, rate-limit, network error, a private or
170 /// renamed tap, a 5xx — is an [`AdapterError::Command`], **not** "absent".
171 /// Treating an infrastructure error as absence would trigger a spurious
172 /// create that clones the tap and could overwrite an existing formula.
173 ///
174 /// A spawn failure (the port could not run `gh`) is a genuine
175 /// [`AdapterError::Io`].
176 fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
177 let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
178 let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
179 let out = ctx
180 .runner
181 .run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
182 .map_err(|e| AdapterError::Io {
183 command: cmd.rendered(),
184 source: e.to_string(),
185 })?;
186 if out.status == Some(0) {
187 return Ok(true);
188 }
189 // A 404 is the only non-zero exit that means "absent". `gh` prints
190 // `Not Found (HTTP 404)`; match the stable `404` token on either stream.
191 if out.stderr.contains("404") || out.stdout.contains("404") {
192 return Ok(false);
193 }
194 let detail = if out.stderr.trim().is_empty() {
195 out.stdout
196 } else {
197 out.stderr
198 };
199 Err(AdapterError::Command {
200 command: cmd.rendered(),
201 code: out.status,
202 stderr: detail,
203 })
204 }
205
206 /// The `brew bump-formula-pr` command for an existing formula, carrying the
207 /// threaded release tarball `--url` (+ `--sha256` when a digest is present).
208 /// Options precede the `--` terminator so a formula name is never parsed as a
209 /// flag. Unchanged from the pre-bootstrap behaviour.
210 fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
211 let mut args: Vec<String> = match self.adapter {
212 Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
213 _ => vec!["bump-formula-pr".into()],
214 };
215 if let Some(tarball) = tarball {
216 args.push("--url".into());
217 args.push(tarball.url.clone());
218 if let Some(sha256) = &tarball.sha256 {
219 args.push("--sha256".into());
220 args.push(sha256.clone());
221 }
222 }
223 args.push("--".into());
224 args.push(name.to_string());
225 PlannedCommand {
226 program: "brew".into(),
227 args,
228 }
229 }
230
231 /// A **fresh, unpredictable** scratch checkout the create path clones the tap
232 /// into. Unique per attempt (pid + a monotonic-ish nanosecond stamp) so:
233 /// concurrent cuts/tests never collide; a retry never trips over a prior
234 /// attempt's leftover dir (the old "deterministic" path made `gh repo clone`
235 /// fail into a non-empty dir); and the unpredictable name defeats the classic
236 /// world-writable-`/tmp` symlink pre-creation (TOCTOU) attack. The file write
237 /// additionally uses create-new semantics (see [`Self::write_formula`]).
238 fn fresh_workdir(name: &str, version: &str) -> PathBuf {
239 let nanos = std::time::SystemTime::now()
240 .duration_since(std::time::UNIX_EPOCH)
241 .map_or(0, |d| d.as_nanos());
242 std::env::temp_dir().join(format!(
243 "ossctl-homebrew-{name}-{version}-{}-{nanos}",
244 std::process::id()
245 ))
246 }
247
248 /// The branch the create path commits the new formula on.
249 fn create_branch(name: &str, version: &str) -> String {
250 format!("ossctl-homebrew-{name}-{version}")
251 }
252
253 /// The commit/PR title for a first formula.
254 fn create_title(name: &str, version: &str) -> String {
255 format!("{name} {version} (new formula)")
256 }
257
258 /// The ordered git/`gh` commands the create path runs (clone → branch → add →
259 /// commit → push → PR), into the pre-computed `workdir`. The generated `.rb`
260 /// is written to disk *between* the clone and the `add` (see
261 /// [`Self::publish`]); these are only the process steps, shared by
262 /// [`Self::dry_run`]'s preview and [`Self::publish`].
263 ///
264 /// `sha256_present` gates two things: a **draft** PR and a blocker in the body.
265 /// When the source-tarball digest is not yet known (the coordinator threads
266 /// `sha256: None` pre-tag), the generated formula carries only a `sha256`
267 /// TODO and would fail `brew audit` / cannot install — so the PR is opened as a
268 /// draft whose body states the one remaining manual step, rather than a
269 /// mergeable-looking PR that is silently broken.
270 fn create_commands(
271 tap: &str,
272 name: &str,
273 version: &str,
274 workdir: &str,
275 sha256_present: bool,
276 ) -> Vec<PlannedCommand> {
277 let branch = Self::create_branch(name, version);
278 let title = Self::create_title(name, version);
279 let formula_rel = format!("Formula/{name}.rb");
280 let body = if sha256_present {
281 "Automated first-formula bootstrap by ossctl.".to_string()
282 } else {
283 "Automated first-formula bootstrap by ossctl.\n\n**Blocked:** the \
284 `sha256` of the published release tarball is not yet known at cut \
285 time (the tag archive does not exist until after publish). Fill in \
286 the `sha256` once the tag is pushed, then mark this PR ready."
287 .to_string()
288 };
289 let mut pr = vec![
290 "pr".to_string(),
291 "create".to_string(),
292 "--repo".to_string(),
293 tap.to_string(),
294 "--head".to_string(),
295 branch.clone(),
296 "--title".to_string(),
297 title.clone(),
298 "--body".to_string(),
299 body,
300 ];
301 if !sha256_present {
302 pr.push("--draft".to_string());
303 }
304 vec![
305 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
306 PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
307 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
308 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
309 // inherits no `user.name`/`user.email`, so on a clean CI runner an
310 // identity-less `git commit` fails with "Author identity unknown".
311 // Disable `commit.gpgsign` so a machine with global signing on cannot
312 // hang the automated commit waiting for a passphrase / missing GPG.
313 PlannedCommand::new(
314 "git",
315 &[
316 "-C",
317 workdir,
318 "-c",
319 "user.name=ossctl",
320 "-c",
321 "user.email=ossctl@users.noreply.github.com",
322 "-c",
323 "commit.gpgsign=false",
324 "commit",
325 "-m",
326 &title,
327 ],
328 ),
329 PlannedCommand::new(
330 "git",
331 &["-C", workdir, "push", "--set-upstream", "origin", &branch],
332 ),
333 PlannedCommand {
334 program: "gh".to_string(),
335 args: pr,
336 },
337 ]
338 }
339
340 /// Run the create path: generate the initial formula, clone the tap, write the
341 /// file, commit it on a branch, and open a PR — all effects through the runner
342 /// except the single filesystem write of the generated `.rb`.
343 fn run_create(
344 ctx: &EffectCtx<'_>,
345 t: &AdapterTarget,
346 tap: &str,
347 ) -> Result<PublishReceipt, AdapterError> {
348 // The package name reaches a filesystem path and a git pathspec; reject any
349 // traversal/separator before it can escape the checkout or the `Formula/` dir.
350 validate_package_name(&t.package)?;
351 let tarball =
352 ctx.artifacts
353 .source_tarball
354 .as_ref()
355 .ok_or_else(|| AdapterError::Command {
356 command: "homebrew first-formula".into(),
357 code: None,
358 stderr: "cannot generate a first Homebrew formula without a resolvable GitHub \
359 source-tarball URL (no `origin` GitHub remote?)"
360 .into(),
361 })?;
362 let license = ctx
363 .artifacts
364 .homebrew
365 .as_ref()
366 .and_then(|h| h.license.as_deref());
367 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
368 let formula = render_formula(
369 &t.package,
370 homepage_slug,
371 &tarball.url,
372 tarball.sha256.as_deref(),
373 license,
374 );
375
376 // One workdir, computed once, used by both the clone and the write.
377 let workdir = Self::fresh_workdir(&t.package, &t.version);
378 let workdir_str = workdir.to_string_lossy().to_string();
379 let commands = Self::create_commands(
380 tap,
381 &t.package,
382 &t.version,
383 &workdir_str,
384 tarball.sha256.is_some(),
385 );
386 // 1. clone the tap.
387 run_all(ctx, &commands[..1])?;
388 // 2. write the generated formula into the checkout (create-new: refuses to
389 // overwrite a formula that already exists in the clone — the last-line
390 // guard against a probe/clone race or a mis-detected "absent").
391 Self::write_formula(&workdir, &t.package, &formula, WriteMode::CreateNew)?;
392 // 3. branch → add → commit → push → PR.
393 let outputs = run_all(ctx, &commands[1..])?;
394
395 // Record the PR URL `gh pr create` prints as the receipt's `remote_url`
396 // (the field already existed — recording it is not a JSON-shape change).
397 // `gh` can precede the URL with status lines, so take the last line that
398 // looks like a URL rather than the whole stdout blob.
399 let remote_url = outputs.last().and_then(|o| {
400 o.stdout
401 .lines()
402 .rev()
403 .map(str::trim)
404 .find(|line| line.starts_with("https://"))
405 .map(str::to_string)
406 });
407 Ok(make_receipt(ctx, t, None, remote_url))
408 }
409
410 /// The commit title for a tap-write formula update.
411 fn update_title(name: &str, version: &str) -> String {
412 format!("{name} {version}")
413 }
414
415 /// The ordered git/`gh` commands the *tap-write* path runs: clone → add →
416 /// commit → push **to the tap's default branch** (no branch, no PR — the
417 /// generated `.rb` is what `brew install` resolves). The rendered formula is
418 /// overwritten onto disk *between* the clone (`commands[..1]`) and the `add`
419 /// (`commands[1..]`), exactly like [`Self::create_commands`]; these are only
420 /// the process steps, shared by [`Self::dry_run`]'s preview and
421 /// [`Self::run_tap_write`].
422 ///
423 /// `git push origin HEAD` publishes the freshly-committed default branch (the
424 /// clone checks the default branch out, so `HEAD` is it) — matching the manual
425 /// fallback that pushed the formula straight to the tap.
426 fn update_commands(tap: &str, name: &str, version: &str, workdir: &str) -> Vec<PlannedCommand> {
427 let title = Self::update_title(name, version);
428 let formula_rel = format!("Formula/{name}.rb");
429 vec![
430 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
431 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
432 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
433 // inherits no `user.name`/`user.email`, so on a clean CI runner an
434 // identity-less `git commit` fails with "Author identity unknown".
435 // Disable `commit.gpgsign` so a machine with global signing on cannot
436 // hang the automated commit waiting for a passphrase / missing GPG.
437 PlannedCommand::new(
438 "git",
439 &[
440 "-C",
441 workdir,
442 "-c",
443 "user.name=ossctl",
444 "-c",
445 "user.email=ossctl@users.noreply.github.com",
446 "-c",
447 "commit.gpgsign=false",
448 "commit",
449 "-m",
450 &title,
451 ],
452 ),
453 PlannedCommand::new("git", &["-C", workdir, "push", "origin", "HEAD"]),
454 ]
455 }
456
457 /// Run the *tap-write* path: render the updated formula from the **verified**
458 /// `url` + `sha256`, clone the tap, overwrite `Formula/<name>.rb`, commit, and
459 /// push to the tap's default branch.
460 ///
461 /// **Fail-closed contract.** This path pushes to the tap's default branch — the
462 /// ref `brew install <tap>/<name>` resolves — so it refuses to write a formula
463 /// without a verified `sha256`: a missing tarball, an absent digest, or one that
464 /// is not exactly 64 hex chars is a hard [`AdapterError::Command`], never a TODO
465 /// placeholder. Unlike the create path's draft PR, there is no human review gate
466 /// here, so a guessed/absent/malformed digest would ship a broken install.
467 ///
468 /// **Must already exist.** `resolve_path` chose this path from a `gh api` probe
469 /// that reported the formula present; after cloning, this re-checks that the tap
470 /// actually carries `Formula/<name>.rb` as a *regular file* before overwriting.
471 /// If a probe/clone race left it absent (or it is a symlink/dir), it fails closed
472 /// rather than *synthesize* a new formula straight onto the default branch — that
473 /// would bypass the create path's PR review gate (and, for a symlink, clobber a
474 /// file outside the checkout).
475 ///
476 /// **Ownership-marker safety** (issue `homebrew-tapwrite-preserve-formula`). It
477 /// never blindly overwrites the existing formula. If the tap's current file
478 /// carries the ossctl ownership marker (see [`render_formula`]), it *is* ossctl
479 /// output and is safe to fully regenerate. If it does not (a hand-maintained
480 /// formula with extra `depends_on`s, `resource`s, `caveats`, a custom `test`, …),
481 /// this path performs a **surgical** edit — replacing only the single `url` and
482 /// `sha256` lines via [`surgical_url_sha_edit`] and preserving everything else —
483 /// and fails closed if that formula's shape cannot be edited safely, rather than
484 /// clobbering hand-authored content.
485 ///
486 /// **Idempotent.** It compares the resulting content (regenerated or surgically
487 /// edited) against the tap's current bytes; an exact match is a clean no-op
488 /// success (a resume/re-run at the target version), so it neither rewrites the
489 /// file nor pushes an empty commit.
490 fn run_tap_write(
491 ctx: &EffectCtx<'_>,
492 t: &AdapterTarget,
493 tap: &str,
494 ) -> Result<PublishReceipt, AdapterError> {
495 // The package name reaches a filesystem path and a git pathspec; reject any
496 // traversal/separator before it can escape the checkout or the `Formula/` dir.
497 validate_package_name(&t.package)?;
498 let tarball =
499 ctx.artifacts
500 .source_tarball
501 .as_ref()
502 .ok_or_else(|| AdapterError::Command {
503 command: "homebrew formula update".into(),
504 code: None,
505 stderr: "cannot update the Homebrew formula without a resolvable GitHub \
506 source-tarball URL (no `origin` GitHub remote?)"
507 .into(),
508 })?;
509 let sha256 = tarball
510 .sha256
511 .as_deref()
512 .filter(|s| is_sha256_hex(s))
513 .ok_or_else(|| AdapterError::Command {
514 command: "homebrew formula update".into(),
515 code: None,
516 stderr:
517 "refusing to push a Homebrew formula to the tap's default branch without a \
518 verified sha256 — the digest is absent or not a 64-char hex string (the tag \
519 archive was not fetched and hashed). A formula on the default branch is what \
520 `brew install` resolves, so an unverified digest would ship a broken install"
521 .into(),
522 })?;
523 let license = ctx
524 .artifacts
525 .homebrew
526 .as_ref()
527 .and_then(|h| h.license.as_deref());
528 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
529
530 let workdir = Self::fresh_workdir(&t.package, &t.version);
531 let workdir_str = workdir.to_string_lossy().to_string();
532 let commands = Self::update_commands(tap, &t.package, &t.version, &workdir_str);
533 // 1. clone the tap (its default branch).
534 run_all(ctx, &commands[..1])?;
535 // 2. the formula must already be a regular file in the clone (see the
536 // "Must already exist" contract above) — read its current bytes.
537 let formula_path = workdir.join("Formula").join(format!("{}.rb", t.package));
538 let current = Self::read_existing_formula(&formula_path, &t.package)?;
539 // 3. ownership marker (issue `homebrew-tapwrite-preserve-formula`): only a
540 // formula ossctl itself generated (carrying the marker) is safe to fully
541 // regenerate. An unmarked, hand-maintained formula must NOT be clobbered —
542 // surgically edit only its `url`/`sha256` values (fail-closed on a shape we
543 // cannot safely parse), preserving every hand-authored stanza.
544 //
545 // MIGRATION: a formula generated by the *pre-marker* renderer is unmarked,
546 // so it takes the surgical path on the first cut after this change and stays
547 // on it (the surgical edit deliberately does NOT inject the marker — doing so
548 // would silently claim ownership of a genuinely hand-maintained formula). For
549 // an ossctl-managed tap this is harmless (its formula's url/sha still update);
550 // to opt a legacy formula back into full regeneration, add the marker line by
551 // hand. A newly *created* formula always carries the marker, so this only
552 // affects formulas that predate the marker.
553 let updated = if formula_carries_marker(¤t) {
554 render_formula(
555 &t.package,
556 homepage_slug,
557 &tarball.url,
558 Some(sha256),
559 license,
560 )
561 } else {
562 let current_str = std::str::from_utf8(¤t).map_err(|_| AdapterError::Command {
563 command: "homebrew formula update".into(),
564 code: None,
565 stderr: "refusing to edit the hand-maintained tap formula: it is not valid \
566 UTF-8, so a safe surgical `url`/`sha256` edit cannot be applied \
567 (no ossctl ownership marker present to authorise a full rewrite)"
568 .into(),
569 })?;
570 surgical_url_sha_edit(current_str, &tarball.url, sha256)?
571 };
572 // 4. idempotent no-op: the tap already carries exactly this content.
573 let remote_url = Some(format!(
574 "https://github.com/{tap}/blob/HEAD/Formula/{}.rb",
575 t.package
576 ));
577 if current == updated.as_bytes() {
578 return Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url));
579 }
580 // 5. overwrite the existing formula, then add → commit → push.
581 Self::write_formula(&workdir, &t.package, &updated, WriteMode::Overwrite)?;
582 run_all(ctx, &commands[1..])?;
583 Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url))
584 }
585
586 /// Read the tap's current `Formula/<name>.rb` bytes, enforcing the tap-write
587 /// invariant that it is an **already-present regular file**. A missing file (a
588 /// probe/clone race) or a non-regular node (a symlink the overwrite would follow
589 /// out of the checkout, or a directory) is a fail-closed [`AdapterError`] — never
590 /// a silent create. Uses `symlink_metadata` so a symlink is *detected*, not
591 /// traversed.
592 fn read_existing_formula(path: &std::path::Path, name: &str) -> Result<Vec<u8>, AdapterError> {
593 let meta = std::fs::symlink_metadata(path).map_err(|e| AdapterError::Command {
594 command: "homebrew formula update".into(),
595 code: None,
596 stderr: format!(
597 "the tap was probed as carrying `{name}.rb` but the cloned checkout does not \
598 (`{}`: {e}) — refusing to synthesize a formula on the default branch without the \
599 create-path review gate",
600 path.display()
601 ),
602 })?;
603 if !meta.file_type().is_file() {
604 return Err(AdapterError::Filesystem {
605 path: path.to_string_lossy().to_string(),
606 source: "not a regular file (symlink or directory) — refusing to overwrite".into(),
607 });
608 }
609 std::fs::read(path).map_err(|e| AdapterError::Filesystem {
610 path: path.to_string_lossy().to_string(),
611 source: e.to_string(),
612 })
613 }
614
615 /// Write the generated formula to `<workdir>/Formula/<name>.rb`, creating the
616 /// `Formula/` directory if the freshly-cloned tap does not carry it yet.
617 ///
618 /// This is the one direct-filesystem effect in the adapter — Homebrew has no
619 /// "add a formula" CLI; a new formula *is* a committed file, so `run_all`
620 /// (which only *runs processes*) cannot express it. It is deliberately scoped:
621 /// it writes exactly one file into a private, unpredictable [`Self::fresh_workdir`]
622 /// the calling path just cloned into. A general filesystem port on `EffectCtx`
623 /// is the cleaner long-term home (issue `homebrew-adapter-fs-port`); until then
624 /// this is mapped to a distinct [`AdapterError::Filesystem`] so the effect is
625 /// explicit, not hidden.
626 ///
627 /// [`WriteMode`] gates the open semantics:
628 /// - [`WriteMode::CreateNew`] (the create path) uses **create-new** (`O_EXCL`)
629 /// so it never follows a symlink onto, or truncates, an existing file — which
630 /// also fails loudly if the tap already carries the formula (a last-line guard
631 /// against a mis-detected "absent" formula).
632 /// - [`WriteMode::Overwrite`] (the tap-write path) truncates the already-present
633 /// formula **without** `create` — the caller ([`Self::run_tap_write`]) has
634 /// already verified via [`Self::read_existing_formula`] that it is an existing
635 /// regular file, so an open failure here means it vanished under us (a race),
636 /// which is a fail-closed error rather than a silent create.
637 fn write_formula(
638 workdir: &std::path::Path,
639 name: &str,
640 formula: &str,
641 mode: WriteMode,
642 ) -> Result<(), AdapterError> {
643 let dir = workdir.join("Formula");
644 std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
645 path: dir.to_string_lossy().to_string(),
646 source: e.to_string(),
647 })?;
648 let path = dir.join(format!("{name}.rb"));
649 let mut opts = std::fs::OpenOptions::new();
650 opts.write(true);
651 match mode {
652 WriteMode::CreateNew => {
653 opts.create_new(true);
654 }
655 WriteMode::Overwrite => {
656 opts.truncate(true);
657 }
658 }
659 let mut file = opts.open(&path).map_err(|e| AdapterError::Filesystem {
660 path: path.to_string_lossy().to_string(),
661 source: e.to_string(),
662 })?;
663 std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
664 AdapterError::Filesystem {
665 path: path.to_string_lossy().to_string(),
666 source: e.to_string(),
667 }
668 })
669 }
670}
671
672/// How [`HomebrewAdapter::write_formula`] opens the target `.rb`: create-new
673/// (`O_EXCL`, the first-formula create) or truncate-an-existing-file (the tap-write
674/// bump, whose caller has already proven the file is a present regular file).
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676enum WriteMode {
677 /// Refuse to open an existing file (`O_EXCL`) — the create path's guard.
678 CreateNew,
679 /// Truncate an existing file; **no** `create`, so a vanished file is an error,
680 /// not a silent create — the tap-write path replacing an existing formula.
681 Overwrite,
682}
683
684impl ReleaseAdapter for HomebrewAdapter {
685 fn adapter(&self) -> Adapter {
686 self.adapter
687 }
688
689 fn dry_run(
690 &self,
691 ctx: &EffectCtx<'_>,
692 t: &AdapterTarget,
693 ) -> Result<DryRunReport, AdapterError> {
694 let tarball = ctx.artifacts.source_tarball.as_ref();
695 let path = self.resolve_path(ctx, t)?;
696 let (planned_commands, mut notes) = match path {
697 FormulaPath::Create => {
698 // `tap` is Some whenever resolve_path returned Create.
699 let tap = self
700 .tap(ctx.artifacts.homebrew.as_ref())
701 .unwrap_or_default();
702 let workdir = Self::fresh_workdir(&t.package, &t.version);
703 let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
704 (
705 Self::create_commands(
706 tap,
707 &t.package,
708 &t.version,
709 &workdir.to_string_lossy(),
710 sha256_present,
711 ),
712 vec![format!(
713 "create path: `{}` has no `{}.rb` yet — generating the initial \
714 source-build formula and opening a{} PR",
715 tap,
716 t.package,
717 if sha256_present { "" } else { " draft" }
718 )],
719 )
720 }
721 FormulaPath::TapWrite => {
722 // `tap` is Some whenever resolve_path returned TapWrite.
723 let tap = self
724 .tap(ctx.artifacts.homebrew.as_ref())
725 .unwrap_or_default();
726 let workdir = Self::fresh_workdir(&t.package, &t.version);
727 let mut notes = vec![format!(
728 "tap-write path: `{}` already serves `{}.rb` — rendering the updated \
729 formula and pushing it directly to the tap's default branch (no \
730 `brew`, no PR)",
731 tap, t.package,
732 )];
733 // Surface the fail-closed requirement rather than let publish fail
734 // late: this path refuses to push without a verified 64-hex sha256,
735 // which the coordinator threads only in the post-tag dist phase.
736 if tarball.and_then(|tb| tb.sha256.as_deref()).is_none() {
737 notes.push(
738 "publish will require a verified post-tag sha256 (absent in this \
739 pre-tag preview); the coordinator supplies it after the tag is pushed"
740 .to_string(),
741 );
742 }
743 (
744 Self::update_commands(tap, &t.package, &t.version, &workdir.to_string_lossy()),
745 notes,
746 )
747 }
748 FormulaPath::BumpPr => (
749 vec![self.bump_command(tarball, &t.package)],
750 vec![
751 "bump-PR path: no configured tap — `brew bump-formula-pr` opens a reviewed PR"
752 .to_string(),
753 ],
754 ),
755 };
756 match tarball {
757 Some(tb) => {
758 // The bump-PR path lets `brew` derive the digest from `--url`; the
759 // create / tap-write paths get a verified digest threaded post-tag.
760 let sha = tb.sha256.as_deref().unwrap_or({
761 if path == FormulaPath::BumpPr {
762 "(computed by brew from --url)"
763 } else {
764 "(resolved and verified by the coordinator post-tag)"
765 }
766 });
767 notes.push(format!("url: {} ; sha256: {sha}", tb.url));
768 }
769 None => notes
770 .push("source tarball url is resolved by the coordinator at cut time".to_string()),
771 }
772 Ok(DryRunReport {
773 adapter: self.adapter,
774 planned_commands,
775 notes,
776 })
777 }
778
779 fn build(
780 &self,
781 _ctx: &EffectCtx<'_>,
782 _t: &AdapterTarget,
783 ) -> Result<BuildArtifacts, AdapterError> {
784 // Homebrew has no build phase of its own — it repackages an existing
785 // release artifact. Return an empty manifest rather than shelling out.
786 Ok(BuildArtifacts {
787 adapter: self.adapter,
788 artifacts: vec![],
789 notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
790 })
791 }
792
793 fn publish(
794 &self,
795 ctx: &EffectCtx<'_>,
796 t: &AdapterTarget,
797 ) -> Result<PublishReceipt, AdapterError> {
798 // PER-TARGET IRREVERSIBLE (pushes a formula to the tap, or opens a PR).
799 match self.resolve_path(ctx, t)? {
800 FormulaPath::Create => {
801 let tap = self
802 .tap(ctx.artifacts.homebrew.as_ref())
803 .expect("resolve_path returns Create only when a tap is configured");
804 Self::run_create(ctx, t, tap)
805 }
806 FormulaPath::TapWrite => {
807 let tap = self
808 .tap(ctx.artifacts.homebrew.as_ref())
809 .expect("resolve_path returns TapWrite only when a tap is configured");
810 Self::run_tap_write(ctx, t, tap)
811 }
812 FormulaPath::BumpPr => {
813 let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
814 run_all(ctx, &[cmd])?;
815 Ok(make_receipt(ctx, t, None, None))
816 }
817 }
818 }
819
820 fn verify(
821 &self,
822 _ctx: &EffectCtx<'_>,
823 _receipt: &PublishReceipt,
824 ) -> Result<VerifyOutcome, AdapterError> {
825 // A tap/core formula is not observable through RegistryQuery; report the
826 // honest "cannot check" rather than a false Missing (ADR-0002 §1).
827 Ok(VerifyOutcome::Unknown)
828 }
829
830 fn timeout(&self) -> Duration {
831 Duration::from_secs(600)
832 }
833}
834
835/// Render a source-build Homebrew formula for `name` at `url`.
836///
837/// Produces the same shape as ossctl's own hand-written 0.1.0 formula: a cargo
838/// source build (`depends_on "rust" => :build` + `cargo install`). The install
839/// stanza is deliberately Rust-specific — the two consumers (`ossctl`,
840/// `issuectl`) are cargo CLIs, and the issue this implements reproduces that
841/// formula; a non-Rust source build is a documented follow-up.
842///
843/// `sha256`/`license` are optional: an absent `sha256` (the coordinator cannot
844/// hash the pushed tag archive before it exists — see the coordinator's
845/// `source_tarball` docs) emits a `TODO` placeholder the maintainer completes,
846/// mirroring the 0.1.0 hand-fill; an absent `license` omits the stanza.
847///
848/// Every generated formula opens with the [`FORMULA_MARKER_PREFIX`] ownership
849/// marker as its first line, so a later tap-write can recognise its own output and
850/// fully regenerate it — while refusing to clobber a hand-maintained (unmarked)
851/// formula.
852///
853/// `pub(super)` so the adapter tests can compute the exact expected bytes when
854/// seeding a fake tap clone (the tap-write idempotency no-op is a byte-compare).
855pub(super) fn render_formula(
856 name: &str,
857 homepage_slug: Option<&str>,
858 url: &str,
859 sha256: Option<&str>,
860 license: Option<&str>,
861) -> String {
862 let class = formula_class(name);
863 // Every value interpolated into a Ruby double-quoted literal is escaped, so a
864 // `"` / `\` in a contract-supplied value cannot break out of the string (or
865 // inject Ruby). `name` reaches only `desc` and the `bin/"…"` test — the class
866 // name is already alphanumeric-only.
867 let name_lit = ruby_escape(name);
868 let homepage = homepage_slug.map_or_else(
869 || ruby_escape(url),
870 |s| ruby_escape(&format!("https://github.com/{s}")),
871 );
872 let url_lit = ruby_escape(url);
873 let sha_line = match sha256 {
874 Some(sha) => format!(" sha256 \"{}\"", ruby_escape(sha)),
875 None => " # TODO: sha256 of the published release tarball \
876 (unavailable at cut time — fill in after the tag archive exists)"
877 .to_string(),
878 };
879 let license_line = license
880 .map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
881 .unwrap_or_default();
882 let marker = format!("{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})");
883 format!(
884 "{marker}\n\
885 class {class} < Formula\n\
886 \x20 desc \"{name_lit}\"\n\
887 \x20 homepage \"{homepage}\"\n\
888 \x20 url \"{url_lit}\"\n\
889 {sha_line}\n\
890 {license_line}\
891 \n\
892 \x20 depends_on \"rust\" => :build\n\
893 \n\
894 \x20 def install\n\
895 \x20 system \"cargo\", \"install\", *std_cargo_args\n\
896 \x20 end\n\
897 \n\
898 \x20 test do\n\
899 \x20 system bin/\"{name_lit}\", \"--version\"\n\
900 \x20 end\n\
901 end\n"
902 )
903}
904
905/// Escape a value for inclusion in a Ruby double-quoted string literal:
906/// backslashes first, then double quotes, then `#`. Prevents a contract-supplied
907/// `"` or `\` from terminating the literal, and — critically — escaping `#` closes
908/// Ruby's `#{…}` string **interpolation**, which would otherwise evaluate arbitrary
909/// Ruby (code execution when `brew` loads the formula) from a value like
910/// `#{system('…')}`. `\#` renders as a literal `#`, so escaping every `#` is safe.
911fn ruby_escape(s: &str) -> String {
912 s.replace('\\', "\\\\")
913 .replace('"', "\\\"")
914 .replace('#', "\\#")
915}
916
917/// Surgically update **only** the double-quoted value of the single `url "…"` and
918/// `sha256 "…"` stanzas of a hand-maintained (unmarked) formula, preserving every
919/// other byte verbatim — `depends_on`s, `resource` blocks, `caveats`, `service`,
920/// custom `test`, comments, blank lines, the file's indentation, any trailing
921/// stanza options (`url "…", using: :git`, `sha256 "…" => :arm64`), inline `#`
922/// comments, and the original line endings (LF or CRLF).
923///
924/// **Only the quoted value is rewritten, in place** (not the whole line): the bytes
925/// before the opening quote (indent + keyword + spacing) and everything after the
926/// closing quote are copied through untouched. This is what keeps trailing options,
927/// comments, and a CRLF `\r` intact, so an already-current formula stays a
928/// byte-for-byte no-op.
929///
930/// **Fail-closed.** The tap-write path pushes straight to the tap's default branch,
931/// so this refuses to guess. A stanza matches only Homebrew's canonical
932/// `<indent><keyword> "<value>"` shape (keyword as a whole token, its argument the
933/// immediately-following double-quoted literal); anything else — `url = x`,
934/// `url(...)`, a parenthesized/heredoc form — simply does not match. It then requires
935/// **exactly one** `url` line and **exactly one** `sha256` line, and every matched
936/// literal to be properly closed. Zero matches (no editable stanza), multiple matches
937/// (a `resource` block's own pair, an arch-conditional bottle/`sha256` set), or an
938/// unterminated literal (a continuation line) is a hard [`AdapterError`] — never a
939/// partial or ambiguous rewrite. The interpolated `url`/`sha256` are [`ruby_escape`]d
940/// exactly as the full renderer escapes them.
941fn surgical_url_sha_edit(current: &str, url: &str, sha256: &str) -> Result<String, AdapterError> {
942 // A canonical stanza line: `<indent><keyword> "<value>"…` — the keyword is a
943 // whole token (whitespace follows it, so `url` never matches `urls`/`url_x`), and
944 // its argument is the immediately-following double-quoted literal (so `url = x`
945 // and `url(...)` do not match and are left for the fail-closed hit-count guard).
946 fn is_stanza_line(line: &str, keyword: &str) -> bool {
947 let trimmed = line.trim_start();
948 let Some(rest) = trimmed.strip_prefix(keyword) else {
949 return false;
950 };
951 rest.starts_with(|c: char| c.is_ascii_whitespace()) && rest.trim_start().starts_with('"')
952 }
953
954 // Replace the contents of the first double-quoted literal on `line` with
955 // `new_value` (ruby-escaped), preserving the prefix (indent + keyword + spacing)
956 // and the entire suffix after the closing quote (trailing options, `# comments`,
957 // and the CRLF `\r`). Returns `None` if the literal is not closed (a malformed or
958 // continuation line) so the caller can fail closed. The closing quote is the next
959 // un-escaped `"`; `"`/`\` are ASCII, so byte scanning never splits a UTF-8 char.
960 fn rewrite_quoted_value(line: &str, new_value: &str) -> Option<String> {
961 let open = line.find('"')?;
962 let after = &line[open + 1..];
963 let bytes = after.as_bytes();
964 let mut i = 0;
965 let close = loop {
966 match bytes.get(i)? {
967 b'\\' => i += 2, // skip the escaped char (a trailing `\` runs off → None)
968 b'"' => break i,
969 _ => i += 1,
970 }
971 };
972 let prefix = &line[..open];
973 let suffix = &after[close + 1..];
974 Some(format!("{prefix}\"{}\"{suffix}", ruby_escape(new_value)))
975 }
976
977 let mut url_hits = 0usize;
978 let mut sha_hits = 0usize;
979 let mut malformed = false;
980 // Preserve the input's exact line structure — indentation, blank lines, a
981 // trailing newline (a final empty segment rejoins to reproduce it), and each
982 // line's own `\r` — by splitting on '\n' and writing back with '\n'.
983 let mut rebuilt = String::with_capacity(current.len() + 64);
984 for (idx, line) in current.split('\n').enumerate() {
985 if idx > 0 {
986 rebuilt.push('\n');
987 }
988 let (hits, new_value) = if is_stanza_line(line, "url") {
989 (Some(&mut url_hits), url)
990 } else if is_stanza_line(line, "sha256") {
991 (Some(&mut sha_hits), sha256)
992 } else {
993 (None, "")
994 };
995 if let Some(counter) = hits {
996 *counter += 1;
997 if let Some(rewritten) = rewrite_quoted_value(line, new_value) {
998 rebuilt.push_str(&rewritten);
999 } else {
1000 malformed = true;
1001 rebuilt.push_str(line);
1002 }
1003 } else {
1004 rebuilt.push_str(line);
1005 }
1006 }
1007
1008 if url_hits != 1 || sha_hits != 1 || malformed {
1009 return Err(AdapterError::Command {
1010 command: "homebrew formula update".into(),
1011 code: None,
1012 stderr: format!(
1013 "refusing to update the hand-maintained tap formula: it carries no ossctl \
1014 ownership marker, and a safe surgical `url`/`sha256` edit needs exactly one \
1015 canonical `url \"…\"` line and one `sha256 \"…\"` line with a properly closed \
1016 literal, but found {url_hits} `url` and {sha_hits} `sha256`{} — update the \
1017 formula by hand, or add the ossctl marker \
1018 (`{FORMULA_MARKER_PREFIX} {FORMULA_TEMPLATE_VERSION})`) as the first line to \
1019 opt into full regeneration",
1020 if malformed {
1021 " (a matched stanza's quoted value was not closed on its line)"
1022 } else {
1023 ""
1024 }
1025 ),
1026 });
1027 }
1028 Ok(rebuilt)
1029}
1030
1031/// Whether `s` is a syntactically valid SHA-256 digest: exactly 64 ASCII hex
1032/// characters. The tap-write fail-closed check rejects an absent OR malformed digest
1033/// (`Some("")`, `Some("garbage")`, a wrong length) — `Some(_)` alone is not proof of
1034/// a verified hash.
1035fn is_sha256_hex(s: &str) -> bool {
1036 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
1037}
1038
1039/// Reject a package name that could escape the `Formula/` directory or the git
1040/// pathspec when interpolated into `Formula/<name>.rb` / `<name>.rb` — an empty
1041/// name, a path separator (`/`, `\`), a `..` traversal component, or a leading `.`.
1042/// The name is otherwise trusted (it reaches `desc`/`bin` via [`ruby_escape`]); this
1043/// guards only the filesystem/path uses.
1044fn validate_package_name(name: &str) -> Result<(), AdapterError> {
1045 let bad = name.is_empty()
1046 || name.starts_with('.')
1047 || name.contains('/')
1048 || name.contains('\\')
1049 || name.split(['/', '\\']).any(|seg| seg == "..");
1050 if bad {
1051 return Err(AdapterError::Filesystem {
1052 path: name.to_string(),
1053 source: "invalid Homebrew package name — must not be empty, start with `.`, or \
1054 contain a path separator or `..` traversal component"
1055 .into(),
1056 });
1057 }
1058 Ok(())
1059}
1060
1061/// Homebrew's formula class name for `name`: alphanumeric runs capitalised and
1062/// concatenated (`my-tool` → `MyTool`, `ossctl` → `Ossctl`). A small, faithful
1063/// subset of Homebrew's `Formulary.class_s` — enough for the ordinary tap names
1064/// this generator targets.
1065///
1066/// A Ruby constant may not begin with a digit, so a leading-digit name is
1067/// prefixed with `X` (as Homebrew itself does: `2fa` → `X2fa`); a name that
1068/// reduces to nothing falls back to `Formula` so the output is always a legal
1069/// constant rather than a syntax error.
1070fn formula_class(name: &str) -> String {
1071 let mut out = String::new();
1072 for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
1073 let mut chars = segment.chars();
1074 if let Some(first) = chars.next() {
1075 out.extend(first.to_uppercase());
1076 out.push_str(chars.as_str());
1077 }
1078 }
1079 if out.is_empty() {
1080 return "Formula".to_string();
1081 }
1082 if out.starts_with(|c: char| c.is_ascii_digit()) {
1083 out.insert(0, 'X');
1084 }
1085 out
1086}