Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions lab/internal/cli/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,19 @@ func runUIChoosing(ctx context.Context, args []string, defaultLab string, choice
options.Resources = guard
}
options.SourceRoot = cwd
// A Formula rebind from the UI saves the launcher init --rebind would, and
// only one that resolves to a build this user owns under the managed
// Forgecell home; otherwise the Formula view says the launcher is unknown.
launcher := initLauncher()
if launcher == "" {
launcher, _ = os.Executable()
}
if root, e := resolvedCheckoutRoot(); e == nil {
if home, e := forgecellHome(root); e == nil {
options.Home = home
options.Launcher = ui.ValidateLauncher(home, launcher)
}
}
// The avatar's name; demo mode never reads git.
options.Operator = operatorName(ctx, cwd)
}
Expand Down
156 changes: 156 additions & 0 deletions lab/internal/formula/dev_launcher_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package formula

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

const devRoot = "/home/op/.forgecell/releases/"

// withCommitTimes replaces the recorded dev-build commit times with times, keyed
// by launcher; a launcher missing from times records none.
func withCommitTimes(t *testing.T, times map[string]int64) {
t.Helper()
previous := devCommitTime
devCommitTime = func(launcher string) int64 { return times[launcher] }
t.Cleanup(func() { devCommitTime = previous })
}

func TestDevBuildRebindNewerIsAcceptedOlderIsRefusedWithReason(t *testing.T) {
early, late := devRoot+"0.16.9-dev.ffff/forgecell", devRoot+"0.16.9-dev.0000/forgecell"
untimed, dirty := devRoot+"0.16.9-dev.abcd/forgecell", devRoot+"0.16.9-dev.abcd.dirty.12345678/forgecell"
twin := devRoot + "0.16.9-dev.1234/forgecell"
withCommitTimes(t, map[string]int64{early: 1_700_000_000, late: 1_700_000_100, twin: 1_700_000_000})
for name, c := range map[string]struct {
current, next, order string
}{
"newer dev build of a newer version": {devRoot + "0.16.8-dev.ffff/forgecell", devRoot + "0.16.9-dev.0000/forgecell", LauncherNewer},
"dev build after its release": {devRoot + "0.16.8/forgecell", devRoot + "0.16.9-dev.abcd/forgecell", LauncherNewer},
"release after its dev build": {devRoot + "0.16.9-dev.abcd/forgecell", devRoot + "0.16.9/forgecell", LauncherNewer},
"older dev build": {devRoot + "0.16.9-dev.0000/forgecell", devRoot + "0.16.8-dev.ffff/forgecell", LauncherOlder},
"dev build of the current release": {devRoot + "0.16.9/forgecell", devRoot + "0.16.9-dev.abcd/forgecell", LauncherOlder},
"same version, later commit, lower hash": {early, late, LauncherNewer},
"same version, earlier commit, higher hash": {late, early, LauncherOlder},
"same version, the next records no time": {early, untimed, LauncherUnordered},
"same version, the current records no time": {untimed, late, LauncherUnordered},
"same version, neither records a time": {untimed, dirty, LauncherUnordered},
"same version, equal commit times": {early, twin, LauncherUnordered},
"identical": {early, early, LauncherSame},
"other installation": {devRoot + "0.16.8/forgecell", "/opt/forgecell/releases/0.16.9/forgecell", LauncherUnrelated},
"not a release": {"/usr/local/bin/forgecell", devRoot + "0.16.9/forgecell", LauncherUnrelated},
"other installation, even with recorded times": {early, "/opt/forgecell/releases/0.16.9-dev.0000/forgecell", LauncherUnrelated},
} {
order, reason := CompareLaunchers(c.current, c.next)
if order != c.order {
t.Errorf("%s: order %s, want %s (%s)", name, order, c.order, reason)
}
if reason == "" || strings.Contains(reason, "/home/op") || strings.Contains(reason, "/opt/") || strings.Contains(reason, "/usr/") {
t.Errorf("%s: reason must be plain and path-free: %q", name, reason)
}
// The rebase check agrees: only a newer build is a launcher upgrade.
if IsReleaseLauncher(c.current) && IsReleaseLauncher(c.next) {
if got := launcherUpgrade(c.current, c.next); got != (c.order == LauncherNewer) {
t.Errorf("%s: launcherUpgrade %v, want %v", name, got, c.order == LauncherNewer)
}
}
}
if _, reason := CompareLaunchers(early, untimed); !strings.Contains(reason, "records no commit time") || !strings.Contains(reason, "cannot tell which is newer") {
t.Errorf("a missing commit time needs the plain reason, got %q", reason)
}
if _, reason := CompareLaunchers(early, twin); !strings.Contains(reason, "same recorded commit time") {
t.Errorf("equal commit times need the plain reason, got %q", reason)
}
if _, reason := CompareLaunchers(late, early); !strings.Contains(reason, "older") || !strings.Contains(reason, "0.16.9-dev.ffff") {
t.Errorf("an older dev build needs a reason naming its version, got %q", reason)
}
if _, reason := CompareLaunchers(devRoot+"0.16.9/forgecell", devRoot+"0.16.8/forgecell"); !strings.Contains(reason, "older") || !strings.Contains(reason, "0.16.8") {
t.Errorf("an older build needs a reason naming its version, got %q", reason)
}
}

func TestDevBuildRebaseOrdersSameVersionDevBuildsByCommitTime(t *testing.T) {
first, second := devRoot+"0.9.2-dev.ffff/forgecell", devRoot+"0.9.2-dev.0000/forgecell"
untimed := devRoot + "0.9.2-dev.abcd/forgecell"
withCommitTimes(t, map[string]int64{first: 1_700_000_000, second: 1_700_000_100})
recipe := strings.ReplaceAll(launcherRecipe, oldLauncher, first)
if _, err := Parse([]byte(recipe)); err != nil {
t.Fatal(err)
}
to := func(launcher string) []byte {
return []byte(strings.Replace(recipe, first+", __adapter, claude", launcher+", __adapter, claude", 1))
}
if err := LauncherOnlyChange([]byte(recipe), to(second)); err != nil {
t.Fatalf("a newer dev build of the same version (alphabetically lower) was refused: %v", err)
}
if err := LauncherOnlyChange(to(second), []byte(recipe)); err == nil {
t.Fatal("an older dev build of the same version was carried as an upgrade")
}
if err := LauncherOnlyChange([]byte(recipe), to(untimed)); err == nil {
t.Fatal("a dev build without a recorded commit time was carried as an upgrade")
}
if err := LauncherOnlyChange([]byte(recipe), to(devRoot+"0.9.3-dev.0000/forgecell")); err != nil {
t.Fatalf("a dev build of a newer version was refused: %v", err)
}
}

func writeDevBuild(t *testing.T, dir string, content []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, DevBuildFile), content, mode); err != nil {
t.Fatal(err)
}
}

func TestDevCommitTimeReadsOnlyATrustworthyRecord(t *testing.T) {
releases := filepath.Join(t.TempDir(), "releases")
record := func(version string, at int64) []byte {
raw, _ := json.Marshal(DevBuild{Version: version, CommitTime: at})
return raw
}
good := filepath.Join(releases, "0.9.2-dev.abcd")
writeDevBuild(t, good, record("0.9.2-dev.abcd", 1_700_000_000), 0600)
if got := devCommitTime(filepath.Join(good, "forgecell")); got != 1_700_000_000 {
t.Fatalf("a valid record reads %d", got)
}
cases := map[string][]byte{
"0.9.2-dev.0001": record("0.9.2-dev.other", 1_700_000_000),
"0.9.2-dev.0002": record("0.9.2-dev.0002", 0),
"0.9.2-dev.0003": record("0.9.2-dev.0003", -5),
"0.9.2-dev.0004": []byte(`{"version":"0.9.2-dev.0004","commitTime":1700000000,"extra":1}`),
"0.9.2-dev.0005": []byte(`{"version":"0.9.2-dev.0005","commitTime":1700000000}{}`),
"0.9.2-dev.0006": []byte("not json"),
"0.9.2-dev.0007": []byte(`{"version":"0.9.2-dev.0007","commitTime":1700000000,"pad":"` + strings.Repeat("x", 2000) + `"}`),
"0.9.2-dev.0008": record("0.9.2-dev.0008", maxCommitTime+1),
}
for version, raw := range cases {
writeDevBuild(t, filepath.Join(releases, version), raw, 0600)
if got := devCommitTime(filepath.Join(releases, version, "forgecell")); got != 0 {
t.Errorf("%s: an untrustworthy record read as %d", version, got)
}
}
if got := devCommitTime(filepath.Join(releases, "0.9.2-dev.missing", "forgecell")); got != 0 {
t.Errorf("a missing record read as %d", got)
}
linked := filepath.Join(releases, "0.9.2-dev.link")
if err := os.MkdirAll(linked, 0700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(good, DevBuildFile), filepath.Join(linked, DevBuildFile)); err != nil {
t.Fatal(err)
}
if got := devCommitTime(filepath.Join(linked, "forgecell")); got != 0 {
t.Errorf("a symlinked record was followed: %d", got)
}
linkedDir := filepath.Join(releases, "0.9.2-dev.abce")
if err := os.Symlink(good, linkedDir); err != nil {
t.Fatal(err)
}
if got := devCommitTime(filepath.Join(linkedDir, "forgecell")); got != 0 {
t.Errorf("a symlinked release directory was followed: %d", got)
}
}
133 changes: 132 additions & 1 deletion lab/internal/formula/formula.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"regexp"
"slices"
"strings"
"syscall"
"unicode"
"unicode/utf8"

Expand Down Expand Up @@ -638,7 +639,137 @@ func splitReleaseLauncher(p string) (root, version string) {
func launcherUpgrade(previous, next string) bool {
oldRoot, oldVersion := splitReleaseLauncher(previous)
newRoot, newVersion := splitReleaseLauncher(next)
return oldRoot == newRoot && compareReleaseVersions(newVersion, oldVersion) > 0
if oldRoot != newRoot {
return false
}
c, ordered := releaseOrder(newVersion, oldVersion, func() (int64, int64) { return devCommitTime(next), devCommitTime(previous) })
return ordered && c > 0
}

// isDevBuild reports a dev build's pre-release (dev.<rev>[.dirty.<digest>]).
func isDevBuild(pre string) bool { return strings.HasPrefix(pre, "dev.") }

// sameVersionDevBuilds reports two different dev builds of one major.minor.patch,
// whose identifiers are commit hashes that sort alphabetically, not by time.
func sameVersionDevBuilds(a, b string) bool {
aCore, aPre, _ := strings.Cut(a, "-")
bCore, bPre, _ := strings.Cut(b, "-")
return aCore == bCore && aPre != bPre && isDevBuild(aPre) && isDevBuild(bPre)
}

// releaseOrder is compareReleaseVersions, except for two dev builds of the same
// major.minor.patch from different builds: those are ordered by the commit times
// their installs recorded (times reads them, a then b, only then), and only when
// both carry a time and the times differ. Otherwise they are unordered, and a
// rebind between them is never treated as an upgrade.
func releaseOrder(a, b string, times func() (aTime, bTime int64)) (int, bool) {
if !sameVersionDevBuilds(a, b) {
return compareReleaseVersions(a, b), true
}
aTime, bTime := times()
switch {
case aTime <= 0 || bTime <= 0 || aTime == bTime:
return 0, false
case aTime > bTime:
return 1, true
}
return -1, true
}

// DevBuildFile is the record forgecell dev install writes beside a dev build's
// manifest.json: the release version and the Unix committer time of the source
// commit it was built from, as git log -1 --format=%ct reported it.
const DevBuildFile = "dev-build.json"

// DevBuild is the content of DevBuildFile.
type DevBuild struct {
Version string `json:"version"`
CommitTime int64 `json:"commitTime"`
}

// maxDevBuildBytes bounds DevBuildFile; maxCommitTime (year 3000) bounds the time.
const (
maxDevBuildBytes = 1024
maxCommitTime = 32503680000
)

// devCommitTime is the commit time the dev build at launcher recorded, or 0 when
// it records none or the record is not trustworthy: the release directory must
// be a real directory and the record a small regular file owned by this user,
// opened without following a symlink, that decodes strictly and names the
// directory's own version. Tests replace it.
var devCommitTime = func(launcher string) int64 {
dir := filepath.Dir(launcher)
if st, err := os.Lstat(dir); err != nil || !st.IsDir() {
return 0
}
f, err := os.OpenFile(filepath.Join(dir, DevBuildFile), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return 0
}
defer f.Close()
st, err := f.Stat()
if err != nil || !st.Mode().IsRegular() || st.Size() > maxDevBuildBytes {
return 0
}
if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != os.Getuid() {
return 0
}
raw, err := io.ReadAll(io.LimitReader(f, maxDevBuildBytes+1))
if err != nil || len(raw) > maxDevBuildBytes {
return 0
}
var record DevBuild
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if dec.Decode(&record) != nil || dec.More() || record.Version != filepath.Base(dir) || record.CommitTime <= 0 || record.CommitTime > maxCommitTime {
return 0
}
return record.CommitTime
}

// Launcher orders, from CompareLaunchers.
const (
LauncherNewer = "newer"
LauncherSame = "same"
LauncherOlder = "older"
LauncherUnordered = "unordered"
LauncherUnrelated = "unrelated"
)

// CompareLaunchers orders the build of next against the build of current for a
// rebind: newer, same, older, unordered (two dev builds of one version whose
// recorded commit times do not order them) or unrelated (not both release launchers of one
// installation, so there is no version order). reason is a plain sentence that
// names versions only, never a path.
func CompareLaunchers(current, next string) (order, reason string) {
if current == next {
return LauncherSame, "This build is already the Formula's launcher."
}
if !IsReleaseLauncher(current) || !IsReleaseLauncher(next) {
return LauncherUnrelated, "The Formula's launcher and this build are not both installed releases, so Forgecell cannot order them."
}
oldRoot, oldVersion := splitReleaseLauncher(current)
newRoot, newVersion := splitReleaseLauncher(next)
if oldRoot != newRoot {
return LauncherUnrelated, "This build belongs to another Forgecell installation than the Formula's launcher."
}
var newTime, oldTime int64
c, ordered := releaseOrder(newVersion, oldVersion, func() (int64, int64) {
newTime, oldTime = devCommitTime(next), devCommitTime(current)
return newTime, oldTime
})
switch {
case !ordered && newTime > 0 && newTime == oldTime:
return LauncherUnordered, fmt.Sprintf("This build (%s) and the Formula's launcher (%s) are dev builds of the same version with the same recorded commit time, so Forgecell cannot tell which is newer.", newVersion, oldVersion)
case !ordered:
return LauncherUnordered, fmt.Sprintf("This build (%s) and the Formula's launcher (%s) are dev builds of the same version, and at least one records no commit time, so Forgecell cannot tell which is newer.", newVersion, oldVersion)
case c > 0:
return LauncherNewer, fmt.Sprintf("This build (%s) is newer than the Formula's launcher (%s).", newVersion, oldVersion)
case c < 0:
return LauncherOlder, fmt.Sprintf("This build (%s) is older than the Formula's launcher (%s).", newVersion, oldVersion)
}
return LauncherSame, fmt.Sprintf("This build (%s) is the same version as the Formula's launcher.", newVersion)
}

// compareReleaseVersions orders X.Y.Z[-pre] versions by semantic-version
Expand Down
Loading
Loading