Skip to main content

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}