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, overwrite 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 homebrew distribution adapter, operating as `homebrew-tap` or
61/// `homebrew-core`.
62pub struct HomebrewAdapter {
63 adapter: Adapter,
64}
65
66/// Which formula operation the adapter resolved for a target.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum FormulaPath {
69 /// Generate + PR the initial `<name>.rb` (configured tap, no formula yet).
70 Create,
71 /// Render + write the updated `<name>.rb` directly to the configured tap's
72 /// default branch (configured tap, formula already present) — no `brew`, no
73 /// PR. See the module docs' *tap-write* path.
74 TapWrite,
75 /// `brew bump-formula-pr` an already-present formula — the fallback for a
76 /// target with no configured tap (`homebrew-core`, or an unconfigured
77 /// `homebrew-tap`), where a reviewed PR + full `brew audit` is the right path.
78 BumpPr,
79}
80
81impl HomebrewAdapter {
82 /// Construct for a resolved homebrew adapter identity.
83 #[must_use]
84 pub fn new(adapter: Adapter) -> Self {
85 debug_assert!(matches!(
86 adapter,
87 Adapter::HomebrewTap | Adapter::HomebrewCore
88 ));
89 Self { adapter }
90 }
91
92 /// The destination tap slug (`owner/repo`) for a `homebrew-tap` create, when
93 /// the contract configured one. `None` for `homebrew-core` or an unconfigured
94 /// tap — which pins the adapter to the bump path (no bootstrap destination).
95 fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
96 if self.adapter != Adapter::HomebrewTap {
97 return None;
98 }
99 artifacts.and_then(|h| h.tap.as_deref())
100 }
101
102 /// Decide the formula path for `target`. A `homebrew-tap` with a configured
103 /// tap probes the tap for the formula: absent → [`FormulaPath::Create`],
104 /// present → [`FormulaPath::TapWrite`] (direct write to the tap). Every other
105 /// case (no configured tap: `homebrew-core`, or an unconfigured tap) is
106 /// [`FormulaPath::BumpPr`].
107 ///
108 /// The probe runs through the injected runner (`gh api …/contents/…`); a
109 /// non-zero exit (a `404`, typically) reads as *absent* → create. A spawn
110 /// failure is a real error and is propagated.
111 fn resolve_path(
112 &self,
113 ctx: &EffectCtx<'_>,
114 t: &AdapterTarget,
115 ) -> Result<FormulaPath, AdapterError> {
116 let homebrew = ctx.artifacts.homebrew.as_ref();
117 match self.tap(homebrew) {
118 Some(tap) => {
119 if Self::formula_exists(ctx, tap, &t.package)? {
120 Ok(FormulaPath::TapWrite)
121 } else {
122 Ok(FormulaPath::Create)
123 }
124 }
125 None => Ok(FormulaPath::BumpPr),
126 }
127 }
128
129 /// Ask the tap whether `Formula/<name>.rb` already exists, through the runner.
130 ///
131 /// Uses `gh api` against the tap's contents endpoint so no local checkout is
132 /// needed for the probe. Only three outcomes are safe to act on:
133 ///
134 /// - exit `0` (the file is served) ⇒ **present** → bump.
135 /// - a genuine `404` (`gh` renders it as `Not Found (HTTP 404)`) ⇒ **absent**
136 /// → create.
137 /// - **anything else** — auth failure, rate-limit, network error, a private or
138 /// renamed tap, a 5xx — is an [`AdapterError::Command`], **not** "absent".
139 /// Treating an infrastructure error as absence would trigger a spurious
140 /// create that clones the tap and could overwrite an existing formula.
141 ///
142 /// A spawn failure (the port could not run `gh`) is a genuine
143 /// [`AdapterError::Io`].
144 fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
145 let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
146 let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
147 let out = ctx
148 .runner
149 .run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
150 .map_err(|e| AdapterError::Io {
151 command: cmd.rendered(),
152 source: e.to_string(),
153 })?;
154 if out.status == Some(0) {
155 return Ok(true);
156 }
157 // A 404 is the only non-zero exit that means "absent". `gh` prints
158 // `Not Found (HTTP 404)`; match the stable `404` token on either stream.
159 if out.stderr.contains("404") || out.stdout.contains("404") {
160 return Ok(false);
161 }
162 let detail = if out.stderr.trim().is_empty() {
163 out.stdout
164 } else {
165 out.stderr
166 };
167 Err(AdapterError::Command {
168 command: cmd.rendered(),
169 code: out.status,
170 stderr: detail,
171 })
172 }
173
174 /// The `brew bump-formula-pr` command for an existing formula, carrying the
175 /// threaded release tarball `--url` (+ `--sha256` when a digest is present).
176 /// Options precede the `--` terminator so a formula name is never parsed as a
177 /// flag. Unchanged from the pre-bootstrap behaviour.
178 fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
179 let mut args: Vec<String> = match self.adapter {
180 Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
181 _ => vec!["bump-formula-pr".into()],
182 };
183 if let Some(tarball) = tarball {
184 args.push("--url".into());
185 args.push(tarball.url.clone());
186 if let Some(sha256) = &tarball.sha256 {
187 args.push("--sha256".into());
188 args.push(sha256.clone());
189 }
190 }
191 args.push("--".into());
192 args.push(name.to_string());
193 PlannedCommand {
194 program: "brew".into(),
195 args,
196 }
197 }
198
199 /// A **fresh, unpredictable** scratch checkout the create path clones the tap
200 /// into. Unique per attempt (pid + a monotonic-ish nanosecond stamp) so:
201 /// concurrent cuts/tests never collide; a retry never trips over a prior
202 /// attempt's leftover dir (the old "deterministic" path made `gh repo clone`
203 /// fail into a non-empty dir); and the unpredictable name defeats the classic
204 /// world-writable-`/tmp` symlink pre-creation (TOCTOU) attack. The file write
205 /// additionally uses create-new semantics (see [`Self::write_formula`]).
206 fn fresh_workdir(name: &str, version: &str) -> PathBuf {
207 let nanos = std::time::SystemTime::now()
208 .duration_since(std::time::UNIX_EPOCH)
209 .map_or(0, |d| d.as_nanos());
210 std::env::temp_dir().join(format!(
211 "ossctl-homebrew-{name}-{version}-{}-{nanos}",
212 std::process::id()
213 ))
214 }
215
216 /// The branch the create path commits the new formula on.
217 fn create_branch(name: &str, version: &str) -> String {
218 format!("ossctl-homebrew-{name}-{version}")
219 }
220
221 /// The commit/PR title for a first formula.
222 fn create_title(name: &str, version: &str) -> String {
223 format!("{name} {version} (new formula)")
224 }
225
226 /// The ordered git/`gh` commands the create path runs (clone → branch → add →
227 /// commit → push → PR), into the pre-computed `workdir`. The generated `.rb`
228 /// is written to disk *between* the clone and the `add` (see
229 /// [`Self::publish`]); these are only the process steps, shared by
230 /// [`Self::dry_run`]'s preview and [`Self::publish`].
231 ///
232 /// `sha256_present` gates two things: a **draft** PR and a blocker in the body.
233 /// When the source-tarball digest is not yet known (the coordinator threads
234 /// `sha256: None` pre-tag), the generated formula carries only a `sha256`
235 /// TODO and would fail `brew audit` / cannot install — so the PR is opened as a
236 /// draft whose body states the one remaining manual step, rather than a
237 /// mergeable-looking PR that is silently broken.
238 fn create_commands(
239 tap: &str,
240 name: &str,
241 version: &str,
242 workdir: &str,
243 sha256_present: bool,
244 ) -> Vec<PlannedCommand> {
245 let branch = Self::create_branch(name, version);
246 let title = Self::create_title(name, version);
247 let formula_rel = format!("Formula/{name}.rb");
248 let body = if sha256_present {
249 "Automated first-formula bootstrap by ossctl.".to_string()
250 } else {
251 "Automated first-formula bootstrap by ossctl.\n\n**Blocked:** the \
252 `sha256` of the published release tarball is not yet known at cut \
253 time (the tag archive does not exist until after publish). Fill in \
254 the `sha256` once the tag is pushed, then mark this PR ready."
255 .to_string()
256 };
257 let mut pr = vec![
258 "pr".to_string(),
259 "create".to_string(),
260 "--repo".to_string(),
261 tap.to_string(),
262 "--head".to_string(),
263 branch.clone(),
264 "--title".to_string(),
265 title.clone(),
266 "--body".to_string(),
267 body,
268 ];
269 if !sha256_present {
270 pr.push("--draft".to_string());
271 }
272 vec![
273 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
274 PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
275 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
276 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
277 // inherits no `user.name`/`user.email`, so on a clean CI runner an
278 // identity-less `git commit` fails with "Author identity unknown".
279 // Disable `commit.gpgsign` so a machine with global signing on cannot
280 // hang the automated commit waiting for a passphrase / missing GPG.
281 PlannedCommand::new(
282 "git",
283 &[
284 "-C",
285 workdir,
286 "-c",
287 "user.name=ossctl",
288 "-c",
289 "user.email=ossctl@users.noreply.github.com",
290 "-c",
291 "commit.gpgsign=false",
292 "commit",
293 "-m",
294 &title,
295 ],
296 ),
297 PlannedCommand::new(
298 "git",
299 &["-C", workdir, "push", "--set-upstream", "origin", &branch],
300 ),
301 PlannedCommand {
302 program: "gh".to_string(),
303 args: pr,
304 },
305 ]
306 }
307
308 /// Run the create path: generate the initial formula, clone the tap, write the
309 /// file, commit it on a branch, and open a PR — all effects through the runner
310 /// except the single filesystem write of the generated `.rb`.
311 fn run_create(
312 ctx: &EffectCtx<'_>,
313 t: &AdapterTarget,
314 tap: &str,
315 ) -> Result<PublishReceipt, AdapterError> {
316 // The package name reaches a filesystem path and a git pathspec; reject any
317 // traversal/separator before it can escape the checkout or the `Formula/` dir.
318 validate_package_name(&t.package)?;
319 let tarball =
320 ctx.artifacts
321 .source_tarball
322 .as_ref()
323 .ok_or_else(|| AdapterError::Command {
324 command: "homebrew first-formula".into(),
325 code: None,
326 stderr: "cannot generate a first Homebrew formula without a resolvable GitHub \
327 source-tarball URL (no `origin` GitHub remote?)"
328 .into(),
329 })?;
330 let license = ctx
331 .artifacts
332 .homebrew
333 .as_ref()
334 .and_then(|h| h.license.as_deref());
335 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
336 let formula = render_formula(
337 &t.package,
338 homepage_slug,
339 &tarball.url,
340 tarball.sha256.as_deref(),
341 license,
342 );
343
344 // One workdir, computed once, used by both the clone and the write.
345 let workdir = Self::fresh_workdir(&t.package, &t.version);
346 let workdir_str = workdir.to_string_lossy().to_string();
347 let commands = Self::create_commands(
348 tap,
349 &t.package,
350 &t.version,
351 &workdir_str,
352 tarball.sha256.is_some(),
353 );
354 // 1. clone the tap.
355 run_all(ctx, &commands[..1])?;
356 // 2. write the generated formula into the checkout (create-new: refuses to
357 // overwrite a formula that already exists in the clone — the last-line
358 // guard against a probe/clone race or a mis-detected "absent").
359 Self::write_formula(&workdir, &t.package, &formula, WriteMode::CreateNew)?;
360 // 3. branch → add → commit → push → PR.
361 let outputs = run_all(ctx, &commands[1..])?;
362
363 // Record the PR URL `gh pr create` prints as the receipt's `remote_url`
364 // (the field already existed — recording it is not a JSON-shape change).
365 // `gh` can precede the URL with status lines, so take the last line that
366 // looks like a URL rather than the whole stdout blob.
367 let remote_url = outputs.last().and_then(|o| {
368 o.stdout
369 .lines()
370 .rev()
371 .map(str::trim)
372 .find(|line| line.starts_with("https://"))
373 .map(str::to_string)
374 });
375 Ok(make_receipt(ctx, t, None, remote_url))
376 }
377
378 /// The commit title for a tap-write formula update.
379 fn update_title(name: &str, version: &str) -> String {
380 format!("{name} {version}")
381 }
382
383 /// The ordered git/`gh` commands the *tap-write* path runs: clone → add →
384 /// commit → push **to the tap's default branch** (no branch, no PR — the
385 /// generated `.rb` is what `brew install` resolves). The rendered formula is
386 /// overwritten onto disk *between* the clone (`commands[..1]`) and the `add`
387 /// (`commands[1..]`), exactly like [`Self::create_commands`]; these are only
388 /// the process steps, shared by [`Self::dry_run`]'s preview and
389 /// [`Self::run_tap_write`].
390 ///
391 /// `git push origin HEAD` publishes the freshly-committed default branch (the
392 /// clone checks the default branch out, so `HEAD` is it) — matching the manual
393 /// fallback that pushed the formula straight to the tap.
394 fn update_commands(tap: &str, name: &str, version: &str, workdir: &str) -> Vec<PlannedCommand> {
395 let title = Self::update_title(name, version);
396 let formula_rel = format!("Formula/{name}.rb");
397 vec![
398 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
399 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
400 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
401 // inherits no `user.name`/`user.email`, so on a clean CI runner an
402 // identity-less `git commit` fails with "Author identity unknown".
403 // Disable `commit.gpgsign` so a machine with global signing on cannot
404 // hang the automated commit waiting for a passphrase / missing GPG.
405 PlannedCommand::new(
406 "git",
407 &[
408 "-C",
409 workdir,
410 "-c",
411 "user.name=ossctl",
412 "-c",
413 "user.email=ossctl@users.noreply.github.com",
414 "-c",
415 "commit.gpgsign=false",
416 "commit",
417 "-m",
418 &title,
419 ],
420 ),
421 PlannedCommand::new("git", &["-C", workdir, "push", "origin", "HEAD"]),
422 ]
423 }
424
425 /// Run the *tap-write* path: render the updated formula from the **verified**
426 /// `url` + `sha256`, clone the tap, overwrite `Formula/<name>.rb`, commit, and
427 /// push to the tap's default branch.
428 ///
429 /// **Fail-closed contract.** This path pushes to the tap's default branch — the
430 /// ref `brew install <tap>/<name>` resolves — so it refuses to write a formula
431 /// without a verified `sha256`: a missing tarball, an absent digest, or one that
432 /// is not exactly 64 hex chars is a hard [`AdapterError::Command`], never a TODO
433 /// placeholder. Unlike the create path's draft PR, there is no human review gate
434 /// here, so a guessed/absent/malformed digest would ship a broken install.
435 ///
436 /// **Must already exist.** `resolve_path` chose this path from a `gh api` probe
437 /// that reported the formula present; after cloning, this re-checks that the tap
438 /// actually carries `Formula/<name>.rb` as a *regular file* before overwriting.
439 /// If a probe/clone race left it absent (or it is a symlink/dir), it fails closed
440 /// rather than *synthesize* a new formula straight onto the default branch — that
441 /// would bypass the create path's PR review gate (and, for a symlink, clobber a
442 /// file outside the checkout).
443 ///
444 /// **Idempotent.** It compares the rendered formula against the tap's current
445 /// bytes; an exact match is a clean no-op success (a resume/re-run at the target
446 /// version), so it neither rewrites the file nor pushes an empty commit.
447 fn run_tap_write(
448 ctx: &EffectCtx<'_>,
449 t: &AdapterTarget,
450 tap: &str,
451 ) -> Result<PublishReceipt, AdapterError> {
452 // The package name reaches a filesystem path and a git pathspec; reject any
453 // traversal/separator before it can escape the checkout or the `Formula/` dir.
454 validate_package_name(&t.package)?;
455 let tarball =
456 ctx.artifacts
457 .source_tarball
458 .as_ref()
459 .ok_or_else(|| AdapterError::Command {
460 command: "homebrew formula update".into(),
461 code: None,
462 stderr: "cannot update the Homebrew formula without a resolvable GitHub \
463 source-tarball URL (no `origin` GitHub remote?)"
464 .into(),
465 })?;
466 let sha256 = tarball
467 .sha256
468 .as_deref()
469 .filter(|s| is_sha256_hex(s))
470 .ok_or_else(|| AdapterError::Command {
471 command: "homebrew formula update".into(),
472 code: None,
473 stderr:
474 "refusing to push a Homebrew formula to the tap's default branch without a \
475 verified sha256 — the digest is absent or not a 64-char hex string (the tag \
476 archive was not fetched and hashed). A formula on the default branch is what \
477 `brew install` resolves, so an unverified digest would ship a broken install"
478 .into(),
479 })?;
480 let license = ctx
481 .artifacts
482 .homebrew
483 .as_ref()
484 .and_then(|h| h.license.as_deref());
485 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
486 let formula = render_formula(
487 &t.package,
488 homepage_slug,
489 &tarball.url,
490 Some(sha256),
491 license,
492 );
493
494 let workdir = Self::fresh_workdir(&t.package, &t.version);
495 let workdir_str = workdir.to_string_lossy().to_string();
496 let commands = Self::update_commands(tap, &t.package, &t.version, &workdir_str);
497 // 1. clone the tap (its default branch).
498 run_all(ctx, &commands[..1])?;
499 // 2. the formula must already be a regular file in the clone (see the
500 // "Must already exist" contract above) — read its current bytes.
501 let formula_path = workdir.join("Formula").join(format!("{}.rb", t.package));
502 let current = Self::read_existing_formula(&formula_path, &t.package)?;
503 // 3. idempotent no-op: the tap already carries this exact formula.
504 let remote_url = Some(format!(
505 "https://github.com/{tap}/blob/HEAD/Formula/{}.rb",
506 t.package
507 ));
508 if current == formula.as_bytes() {
509 return Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url));
510 }
511 // 4. overwrite the existing formula, then add → commit → push.
512 Self::write_formula(&workdir, &t.package, &formula, WriteMode::Overwrite)?;
513 run_all(ctx, &commands[1..])?;
514 Ok(make_receipt(ctx, t, Some(sha256.to_string()), remote_url))
515 }
516
517 /// Read the tap's current `Formula/<name>.rb` bytes, enforcing the tap-write
518 /// invariant that it is an **already-present regular file**. A missing file (a
519 /// probe/clone race) or a non-regular node (a symlink the overwrite would follow
520 /// out of the checkout, or a directory) is a fail-closed [`AdapterError`] — never
521 /// a silent create. Uses `symlink_metadata` so a symlink is *detected*, not
522 /// traversed.
523 fn read_existing_formula(path: &std::path::Path, name: &str) -> Result<Vec<u8>, AdapterError> {
524 let meta = std::fs::symlink_metadata(path).map_err(|e| AdapterError::Command {
525 command: "homebrew formula update".into(),
526 code: None,
527 stderr: format!(
528 "the tap was probed as carrying `{name}.rb` but the cloned checkout does not \
529 (`{}`: {e}) — refusing to synthesize a formula on the default branch without the \
530 create-path review gate",
531 path.display()
532 ),
533 })?;
534 if !meta.file_type().is_file() {
535 return Err(AdapterError::Filesystem {
536 path: path.to_string_lossy().to_string(),
537 source: "not a regular file (symlink or directory) — refusing to overwrite".into(),
538 });
539 }
540 std::fs::read(path).map_err(|e| AdapterError::Filesystem {
541 path: path.to_string_lossy().to_string(),
542 source: e.to_string(),
543 })
544 }
545
546 /// Write the generated formula to `<workdir>/Formula/<name>.rb`, creating the
547 /// `Formula/` directory if the freshly-cloned tap does not carry it yet.
548 ///
549 /// This is the one direct-filesystem effect in the adapter — Homebrew has no
550 /// "add a formula" CLI; a new formula *is* a committed file, so `run_all`
551 /// (which only *runs processes*) cannot express it. It is deliberately scoped:
552 /// it writes exactly one file into a private, unpredictable [`Self::fresh_workdir`]
553 /// the calling path just cloned into. A general filesystem port on `EffectCtx`
554 /// is the cleaner long-term home (issue `homebrew-adapter-fs-port`); until then
555 /// this is mapped to a distinct [`AdapterError::Filesystem`] so the effect is
556 /// explicit, not hidden.
557 ///
558 /// [`WriteMode`] gates the open semantics:
559 /// - [`WriteMode::CreateNew`] (the create path) uses **create-new** (`O_EXCL`)
560 /// so it never follows a symlink onto, or truncates, an existing file — which
561 /// also fails loudly if the tap already carries the formula (a last-line guard
562 /// against a mis-detected "absent" formula).
563 /// - [`WriteMode::Overwrite`] (the tap-write path) truncates the already-present
564 /// formula **without** `create` — the caller ([`Self::run_tap_write`]) has
565 /// already verified via [`Self::read_existing_formula`] that it is an existing
566 /// regular file, so an open failure here means it vanished under us (a race),
567 /// which is a fail-closed error rather than a silent create.
568 fn write_formula(
569 workdir: &std::path::Path,
570 name: &str,
571 formula: &str,
572 mode: WriteMode,
573 ) -> Result<(), AdapterError> {
574 let dir = workdir.join("Formula");
575 std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
576 path: dir.to_string_lossy().to_string(),
577 source: e.to_string(),
578 })?;
579 let path = dir.join(format!("{name}.rb"));
580 let mut opts = std::fs::OpenOptions::new();
581 opts.write(true);
582 match mode {
583 WriteMode::CreateNew => {
584 opts.create_new(true);
585 }
586 WriteMode::Overwrite => {
587 opts.truncate(true);
588 }
589 }
590 let mut file = opts.open(&path).map_err(|e| AdapterError::Filesystem {
591 path: path.to_string_lossy().to_string(),
592 source: e.to_string(),
593 })?;
594 std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
595 AdapterError::Filesystem {
596 path: path.to_string_lossy().to_string(),
597 source: e.to_string(),
598 }
599 })
600 }
601}
602
603/// How [`HomebrewAdapter::write_formula`] opens the target `.rb`: create-new
604/// (`O_EXCL`, the first-formula create) or truncate-an-existing-file (the tap-write
605/// bump, whose caller has already proven the file is a present regular file).
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607enum WriteMode {
608 /// Refuse to open an existing file (`O_EXCL`) — the create path's guard.
609 CreateNew,
610 /// Truncate an existing file; **no** `create`, so a vanished file is an error,
611 /// not a silent create — the tap-write path replacing an existing formula.
612 Overwrite,
613}
614
615impl ReleaseAdapter for HomebrewAdapter {
616 fn adapter(&self) -> Adapter {
617 self.adapter
618 }
619
620 fn dry_run(
621 &self,
622 ctx: &EffectCtx<'_>,
623 t: &AdapterTarget,
624 ) -> Result<DryRunReport, AdapterError> {
625 let tarball = ctx.artifacts.source_tarball.as_ref();
626 let path = self.resolve_path(ctx, t)?;
627 let (planned_commands, mut notes) = match path {
628 FormulaPath::Create => {
629 // `tap` is Some whenever resolve_path returned Create.
630 let tap = self
631 .tap(ctx.artifacts.homebrew.as_ref())
632 .unwrap_or_default();
633 let workdir = Self::fresh_workdir(&t.package, &t.version);
634 let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
635 (
636 Self::create_commands(
637 tap,
638 &t.package,
639 &t.version,
640 &workdir.to_string_lossy(),
641 sha256_present,
642 ),
643 vec![format!(
644 "create path: `{}` has no `{}.rb` yet — generating the initial \
645 source-build formula and opening a{} PR",
646 tap,
647 t.package,
648 if sha256_present { "" } else { " draft" }
649 )],
650 )
651 }
652 FormulaPath::TapWrite => {
653 // `tap` is Some whenever resolve_path returned TapWrite.
654 let tap = self
655 .tap(ctx.artifacts.homebrew.as_ref())
656 .unwrap_or_default();
657 let workdir = Self::fresh_workdir(&t.package, &t.version);
658 let mut notes = vec![format!(
659 "tap-write path: `{}` already serves `{}.rb` — rendering the updated \
660 formula and pushing it directly to the tap's default branch (no \
661 `brew`, no PR)",
662 tap, t.package,
663 )];
664 // Surface the fail-closed requirement rather than let publish fail
665 // late: this path refuses to push without a verified 64-hex sha256,
666 // which the coordinator threads only in the post-tag dist phase.
667 if tarball.and_then(|tb| tb.sha256.as_deref()).is_none() {
668 notes.push(
669 "publish will require a verified post-tag sha256 (absent in this \
670 pre-tag preview); the coordinator supplies it after the tag is pushed"
671 .to_string(),
672 );
673 }
674 (
675 Self::update_commands(tap, &t.package, &t.version, &workdir.to_string_lossy()),
676 notes,
677 )
678 }
679 FormulaPath::BumpPr => (
680 vec![self.bump_command(tarball, &t.package)],
681 vec![
682 "bump-PR path: no configured tap — `brew bump-formula-pr` opens a reviewed PR"
683 .to_string(),
684 ],
685 ),
686 };
687 match tarball {
688 Some(tb) => {
689 // The bump-PR path lets `brew` derive the digest from `--url`; the
690 // create / tap-write paths get a verified digest threaded post-tag.
691 let sha = tb.sha256.as_deref().unwrap_or({
692 if path == FormulaPath::BumpPr {
693 "(computed by brew from --url)"
694 } else {
695 "(resolved and verified by the coordinator post-tag)"
696 }
697 });
698 notes.push(format!("url: {} ; sha256: {sha}", tb.url));
699 }
700 None => notes
701 .push("source tarball url is resolved by the coordinator at cut time".to_string()),
702 }
703 Ok(DryRunReport {
704 adapter: self.adapter,
705 planned_commands,
706 notes,
707 })
708 }
709
710 fn build(
711 &self,
712 _ctx: &EffectCtx<'_>,
713 _t: &AdapterTarget,
714 ) -> Result<BuildArtifacts, AdapterError> {
715 // Homebrew has no build phase of its own — it repackages an existing
716 // release artifact. Return an empty manifest rather than shelling out.
717 Ok(BuildArtifacts {
718 adapter: self.adapter,
719 artifacts: vec![],
720 notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
721 })
722 }
723
724 fn publish(
725 &self,
726 ctx: &EffectCtx<'_>,
727 t: &AdapterTarget,
728 ) -> Result<PublishReceipt, AdapterError> {
729 // PER-TARGET IRREVERSIBLE (pushes a formula to the tap, or opens a PR).
730 match self.resolve_path(ctx, t)? {
731 FormulaPath::Create => {
732 let tap = self
733 .tap(ctx.artifacts.homebrew.as_ref())
734 .expect("resolve_path returns Create only when a tap is configured");
735 Self::run_create(ctx, t, tap)
736 }
737 FormulaPath::TapWrite => {
738 let tap = self
739 .tap(ctx.artifacts.homebrew.as_ref())
740 .expect("resolve_path returns TapWrite only when a tap is configured");
741 Self::run_tap_write(ctx, t, tap)
742 }
743 FormulaPath::BumpPr => {
744 let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
745 run_all(ctx, &[cmd])?;
746 Ok(make_receipt(ctx, t, None, None))
747 }
748 }
749 }
750
751 fn verify(
752 &self,
753 _ctx: &EffectCtx<'_>,
754 _receipt: &PublishReceipt,
755 ) -> Result<VerifyOutcome, AdapterError> {
756 // A tap/core formula is not observable through RegistryQuery; report the
757 // honest "cannot check" rather than a false Missing (ADR-0002 §1).
758 Ok(VerifyOutcome::Unknown)
759 }
760
761 fn timeout(&self) -> Duration {
762 Duration::from_secs(600)
763 }
764}
765
766/// Render a source-build Homebrew formula for `name` at `url`.
767///
768/// Produces the same shape as ossctl's own hand-written 0.1.0 formula: a cargo
769/// source build (`depends_on "rust" => :build` + `cargo install`). The install
770/// stanza is deliberately Rust-specific — the two consumers (`ossctl`,
771/// `issuectl`) are cargo CLIs, and the issue this implements reproduces that
772/// formula; a non-Rust source build is a documented follow-up.
773///
774/// `sha256`/`license` are optional: an absent `sha256` (the coordinator cannot
775/// hash the pushed tag archive before it exists — see the coordinator's
776/// `source_tarball` docs) emits a `TODO` placeholder the maintainer completes,
777/// mirroring the 0.1.0 hand-fill; an absent `license` omits the stanza.
778///
779/// `pub(super)` so the adapter tests can compute the exact expected bytes when
780/// seeding a fake tap clone (the tap-write idempotency no-op is a byte-compare).
781pub(super) fn render_formula(
782 name: &str,
783 homepage_slug: Option<&str>,
784 url: &str,
785 sha256: Option<&str>,
786 license: Option<&str>,
787) -> String {
788 let class = formula_class(name);
789 // Every value interpolated into a Ruby double-quoted literal is escaped, so a
790 // `"` / `\` in a contract-supplied value cannot break out of the string (or
791 // inject Ruby). `name` reaches only `desc` and the `bin/"…"` test — the class
792 // name is already alphanumeric-only.
793 let name_lit = ruby_escape(name);
794 let homepage = homepage_slug.map_or_else(
795 || ruby_escape(url),
796 |s| ruby_escape(&format!("https://github.com/{s}")),
797 );
798 let url_lit = ruby_escape(url);
799 let sha_line = match sha256 {
800 Some(sha) => format!(" sha256 \"{}\"", ruby_escape(sha)),
801 None => " # TODO: sha256 of the published release tarball \
802 (unavailable at cut time — fill in after the tag archive exists)"
803 .to_string(),
804 };
805 let license_line = license
806 .map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
807 .unwrap_or_default();
808 format!(
809 "class {class} < Formula\n\
810 \x20 desc \"{name_lit}\"\n\
811 \x20 homepage \"{homepage}\"\n\
812 \x20 url \"{url_lit}\"\n\
813 {sha_line}\n\
814 {license_line}\
815 \n\
816 \x20 depends_on \"rust\" => :build\n\
817 \n\
818 \x20 def install\n\
819 \x20 system \"cargo\", \"install\", *std_cargo_args\n\
820 \x20 end\n\
821 \n\
822 \x20 test do\n\
823 \x20 system bin/\"{name_lit}\", \"--version\"\n\
824 \x20 end\n\
825 end\n"
826 )
827}
828
829/// Escape a value for inclusion in a Ruby double-quoted string literal:
830/// backslashes first, then double quotes, then `#`. Prevents a contract-supplied
831/// `"` or `\` from terminating the literal, and — critically — escaping `#` closes
832/// Ruby's `#{…}` string **interpolation**, which would otherwise evaluate arbitrary
833/// Ruby (code execution when `brew` loads the formula) from a value like
834/// `#{system('…')}`. `\#` renders as a literal `#`, so escaping every `#` is safe.
835fn ruby_escape(s: &str) -> String {
836 s.replace('\\', "\\\\")
837 .replace('"', "\\\"")
838 .replace('#', "\\#")
839}
840
841/// Whether `s` is a syntactically valid SHA-256 digest: exactly 64 ASCII hex
842/// characters. The tap-write fail-closed check rejects an absent OR malformed digest
843/// (`Some("")`, `Some("garbage")`, a wrong length) — `Some(_)` alone is not proof of
844/// a verified hash.
845fn is_sha256_hex(s: &str) -> bool {
846 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
847}
848
849/// Reject a package name that could escape the `Formula/` directory or the git
850/// pathspec when interpolated into `Formula/<name>.rb` / `<name>.rb` — an empty
851/// name, a path separator (`/`, `\`), a `..` traversal component, or a leading `.`.
852/// The name is otherwise trusted (it reaches `desc`/`bin` via [`ruby_escape`]); this
853/// guards only the filesystem/path uses.
854fn validate_package_name(name: &str) -> Result<(), AdapterError> {
855 let bad = name.is_empty()
856 || name.starts_with('.')
857 || name.contains('/')
858 || name.contains('\\')
859 || name.split(['/', '\\']).any(|seg| seg == "..");
860 if bad {
861 return Err(AdapterError::Filesystem {
862 path: name.to_string(),
863 source: "invalid Homebrew package name — must not be empty, start with `.`, or \
864 contain a path separator or `..` traversal component"
865 .into(),
866 });
867 }
868 Ok(())
869}
870
871/// Homebrew's formula class name for `name`: alphanumeric runs capitalised and
872/// concatenated (`my-tool` → `MyTool`, `ossctl` → `Ossctl`). A small, faithful
873/// subset of Homebrew's `Formulary.class_s` — enough for the ordinary tap names
874/// this generator targets.
875///
876/// A Ruby constant may not begin with a digit, so a leading-digit name is
877/// prefixed with `X` (as Homebrew itself does: `2fa` → `X2fa`); a name that
878/// reduces to nothing falls back to `Formula` so the output is always a legal
879/// constant rather than a syntax error.
880fn formula_class(name: &str) -> String {
881 let mut out = String::new();
882 for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
883 let mut chars = segment.chars();
884 if let Some(first) = chars.next() {
885 out.extend(first.to_uppercase());
886 out.push_str(chars.as_str());
887 }
888 }
889 if out.is_empty() {
890 return "Formula".to_string();
891 }
892 if out.starts_with(|c: char| c.is_ascii_digit()) {
893 out.insert(0, 'X');
894 }
895 out
896}