diff --git a/internal/plugin/local_test.go b/internal/plugin/local_test.go new file mode 100644 index 00000000000..7038a1d13d2 --- /dev/null +++ b/internal/plugin/local_test.go @@ -0,0 +1,135 @@ +package plugin + +import ( + "os" + "path/filepath" + "testing" + + "go.jetify.com/devbox/nix/flake" +) + +// newTestLocalPlugin writes a plugin.json (with the given create_files content +// file) into a temp dir and returns a *LocalPlugin pointing at it along with the +// path of the referenced content file. +func newTestLocalPlugin(t *testing.T, fileContents string) (*LocalPlugin, string) { + t.Helper() + pluginDir := t.TempDir() + + pluginJSON := `{ + "name": "testplugin", + "create_files": { + "{{ .Virtenv }}/test.txt": "test.txt" + } + }` + if err := os.WriteFile(filepath.Join(pluginDir, pluginConfigName), []byte(pluginJSON), 0o644); err != nil { + t.Fatal(err) + } + + contentPath := filepath.Join(pluginDir, "test.txt") + if err := os.WriteFile(contentPath, []byte(fileContents), 0o644); err != nil { + t.Fatal(err) + } + + local, err := newLocalPlugin(flake.Ref{Type: flake.TypePath, Path: pluginDir}, pluginDir) + if err != nil { + t.Fatal(err) + } + return local, contentPath +} + +// TestLocalPluginHashIncludesCreateFilesContent verifies that editing a file +// referenced by a local plugin's create_files changes the plugin config hash, +// even though devbox.json and the plugin.json are unchanged. This is what allows +// Devbox to detect the change and regenerate the file in the virtenv. +// See https://github.com/jetify-com/devbox/issues/2755. +func TestLocalPluginHashIncludesCreateFilesContent(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + before, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + // Edit the referenced content file without touching plugin.json. + if err := os.WriteFile(contentPath, []byte("456"), 0o644); err != nil { + t.Fatal(err) + } + + after, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + if before == after { + t.Errorf("hash did not change after editing create_files content: %q", before) + } +} + +// TestLocalPluginHashIncludesCreateFilesDestinations verifies that changing a +// create_files destination (a plugin-only field not covered by +// configfile.ConfigFile.Hash()) changes the plugin config hash. +func TestLocalPluginHashIncludesCreateFilesDestinations(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + before, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + // Point the same source file at a different destination in the virtenv. + cfg.CreateFiles = map[string]string{"renamed.txt": "test.txt"} + + after, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + + if before == after { + t.Errorf("hash did not change after changing create_files destination: %q", before) + } +} + +// TestLocalPluginHashStableWithoutContentChange verifies the hash is stable when +// the referenced files don't change. +func TestLocalPluginHashStableWithoutContentChange(t *testing.T) { + local, contentPath := newTestLocalPlugin(t, "123") + + pluginJSON, err := local.Fetch() + if err != nil { + t.Fatal(err) + } + cfg, err := buildConfig(local, filepath.Dir(contentPath), string(pluginJSON)) + if err != nil { + t.Fatal(err) + } + + first, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + second, err := cfg.Hash() + if err != nil { + t.Fatal(err) + } + if first != second { + t.Errorf("hash is not stable: %q != %q", first, second) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index cf2834559e8..28b3c1f44b5 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -17,6 +17,7 @@ import ( "github.com/pkg/errors" "github.com/tailscale/hujson" + "go.jetify.com/devbox/internal/cachehash" "go.jetify.com/devbox/internal/devconfig/configfile" "go.jetify.com/devbox/internal/devpkg" "go.jetify.com/devbox/internal/lock" @@ -70,6 +71,67 @@ func (c *Config) Services() (services.Services, error) { return nil, nil } +// Hash returns a hash of the plugin config. It shadows the embedded +// configfile.ConfigFile.Hash() so that, for local plugins, the plugin-only +// fields and the contents of the files referenced by create_files are folded +// into the hash. +// +// Devbox uses this hash (via Devbox.ConfigHash) to decide whether the cached +// environment is still up to date. configfile.ConfigFile.Hash() only hashes the +// ConfigFile fields, so on its own it omits plugin-only fields (create_files, +// version, ...) and the create_files content files. For a local plugin all of +// these live on disk and can be edited without any corresponding change to +// devbox.json or the lockfile, so without this a local plugin's create_files +// would never be regenerated in the virtenv after the source changes. See +// https://github.com/jetify-com/devbox/issues/2755. +// +// Built-in plugin files are embedded in the Devbox binary (covered by the +// Devbox version in the state hash) and remote plugins are addressed by an +// immutable ref, so for those the config hash alone is sufficient. +func (c *Config) Hash() (string, error) { + configHash, err := c.ConfigFile.Hash() + if err != nil { + return "", err + } + + local, ok := c.Source.(*LocalPlugin) + if !ok { + return configHash, nil + } + + // Hash each referenced file independently, keyed by its source path, rather + // than concatenating raw bytes. Concatenation is ambiguous (different + // file/content splits can produce identical byte streams) and would buffer + // every file at once. + fileHashes := make(map[string]string, len(c.CreateFiles)) + for _, contentPath := range c.CreateFiles { + if contentPath == "" { + continue + } + content, err := local.FileContent(contentPath) + if err != nil { + return "", errors.WithStack(err) + } + fileHashes[contentPath] = cachehash.Bytes(content) + } + + return cachehash.JSON(struct { + ConfigHash string `json:"config_hash"` + CreateFiles map[string]string `json:"create_files"` + Version string `json:"version"` + Readme string `json:"readme"` + RemoveTriggerPackage bool `json:"remove_trigger_package"` + FileHashes map[string]string `json:"file_hashes"` + }{ + ConfigHash: configHash, + CreateFiles: c.CreateFiles, + Version: c.Version, + Readme: c.DeprecatedDescription, + RemoveTriggerPackage: c.RemoveTriggerPackage, + FileHashes: fileHashes, + }) +} + func (m *Manager) CreateFilesForConfig(cfg *Config) error { virtenvPath := filepath.Join(m.ProjectDir(), VirtenvPath) pkg := cfg.Source