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, 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(&current) {
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(&current).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}