From 619db762ebca8bbbdcaf9eac6e2ba44b1c055017 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:04:24 +0000 Subject: [PATCH 1/8] fix: replace shell-based tag discovery --- internal/yae/source.go | 96 ++++++++++++++++++--------- internal/yae/source_test.go | 129 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 32 deletions(-) create mode 100644 internal/yae/source_test.go diff --git a/internal/yae/source.go b/internal/yae/source.go index 5741547..04efdd5 100644 --- a/internal/yae/source.go +++ b/internal/yae/source.go @@ -100,53 +100,85 @@ func (source *Source) Update(sources *Environment, name string, force bool, forc } func (source *Source) fetchLatestGitTag() (string, error) { - if source.Type == "git" { - url, err := url.Parse(source.URL) + if source.Type != "git" { + return "", fmt.Errorf("source is not a git repository") + } - if err != nil { - return "", err + repository, err := repositoryURL(source.URL) + + if err != nil { + return "", err + } + + pattern, err := regexp.Compile(source.TagPredicate) + + if err != nil { + return "", fmt.Errorf("invalid tag_predicate: %w", err) + } + + output, err := command("git", false, "ls-remote", "--tags", "--refs", "--sort=-version:refname", "--", repository) + + if err != nil { + return "", fmt.Errorf("list remote tags: %w", err) + } + + for _, line := range strings.Split(strings.TrimSpace(output), "\n") { + fields := strings.Fields(line) + + if len(fields) != 2 || !strings.HasPrefix(fields[1], "refs/tags/") || strings.HasSuffix(fields[1], "^{}") { + continue } - domain := url.Host - pathSegments := strings.Split(url.Path, "/") - repository := url.Scheme + "://" + domain + "/" + pathSegments[1] + "/" + pathSegments[2] - remotes, err := command("bash", false, "-c", fmt.Sprintf("git ls-remote %s | awk -F'/' '{print $NF}' | sort -V", repository)) + tag := strings.TrimPrefix(fields[1], "refs/tags/") - if err != nil { - return "", err + if !pattern.MatchString(tag) { + continue } - refs := strings.Split(remotes, "\n") - var latest string + version := strings.TrimPrefix(tag, source.TrimTagPrefix) - for i := range refs { - refs[i] = strings.TrimSuffix(refs[i], "^{}") + if version == "" { + return "", fmt.Errorf("tag is empty after trimming its prefix") } - if source.TagPredicate == "" { - latest = refs[len(refs)-2] - } else { - pattern, err := regexp.Compile(source.TagPredicate) + return version, nil + } - if err != nil { - return "", fmt.Errorf("invalid tag_predicate regex pattern: %w", err) - } + return "", fmt.Errorf("no remote tags match tag_predicate") +} - for i := len(refs) - 2; i >= 0; i-- { - if pattern.MatchString(refs[i]) { - latest = refs[i] +func repositoryURL(address string) (string, error) { + parsed, err := url.Parse(address) - break - } - } - } + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil { + return "", fmt.Errorf("git source requires an HTTP(S) URL without credentials") + } - if source.TrimTagPrefix != "" { - latest = strings.TrimPrefix(latest, source.TrimTagPrefix) + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + repositoryEnd := len(segments) + + for index, segment := range segments { + if index >= 2 && (segment == "archive" || segment == "releases" || segment == "-") { + repositoryEnd = index + + break } + } - return latest, nil + if repositoryEnd < 2 || (repositoryEnd == len(segments) && repositoryEnd != 2) { + return "", fmt.Errorf("cannot infer repository: use an archive or release download URL") } - return "", fmt.Errorf("source is not a git repository") + for _, segment := range segments[:repositoryEnd] { + if segment == "" || segment == "." || segment == ".." { + return "", fmt.Errorf("invalid repository path") + } + } + + parsed.Path = "/" + strings.Join(segments[:repositoryEnd], "/") + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + + return parsed.String(), nil } diff --git a/internal/yae/source_test.go b/internal/yae/source_test.go new file mode 100644 index 0000000..d9c9f53 --- /dev/null +++ b/internal/yae/source_test.go @@ -0,0 +1,129 @@ +package yae + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func fakeGit(t *testing.T, output string, failure bool) string { + t.Helper() + + shell, err := exec.LookPath("sh") + + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + arguments := filepath.Join(directory, "arguments") + script := "#!" + shell + "\nprintf '%s\\n' \"$@\" > \"$YAE_TEST_ARGUMENTS\"\nprintf '%s' \"$YAE_TEST_TAGS\"\n" + + if failure { + script += "exit 23\n" + } + + if err := os.WriteFile(filepath.Join(directory, "git"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("YAE_TEST_ARGUMENTS", arguments) + t.Setenv("YAE_TEST_TAGS", output) + + return arguments +} + +func TestLatestGitTag(t *testing.T) { + cases := []struct { + name string + output string + predicate string + prefix string + failure bool + want string + }{ + {name: "tags only", output: "abc\trefs/heads/zzbranch\nabc\trefs/tags/v10\nabc\trefs/tags/v2\n", want: "v10"}, + {name: "annotated tags", output: "abc\trefs/tags/v10^{}\nabc\trefs/tags/v10", want: "v10"}, + {name: "nested tag", output: "abc\trefs/tags/release/v2\n", want: "release/v2"}, + {name: "filter before trimming", output: "abc\trefs/tags/v2-beta\nabc\trefs/tags/v1\n", predicate: "^v[0-9]+$", prefix: "v", want: "1"}, + {name: "no tags"}, + {name: "no match", output: "abc\trefs/tags/v1\n", predicate: "^never$"}, + {name: "invalid regex", predicate: "["}, + {name: "empty trimmed tag", output: "abc\trefs/tags/v1\n", prefix: "v1"}, + {name: "remote failure", failure: true}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fakeGit(t, test.output, test.failure) + + source := Source{Type: "git", URL: "https://example.test/owner/repo/archive/v0.tar.gz", TagPredicate: test.predicate, TrimTagPrefix: test.prefix} + version, err := source.fetchLatestGitTag() + + if test.want == "" { + if err == nil { + t.Fatalf("expected an error, got %q", version) + } + + return + } + + if err != nil || version != test.want { + t.Fatalf("version = %q, error = %v; want %q", version, err, test.want) + } + }) + } +} + +func TestGitURLIsAnArgument(t *testing.T) { + arguments := fakeGit(t, "abc\trefs/tags/v1\n", false) + source := Source{Type: "git", URL: "https://example.test/$(printf${IFS}INJECTED)/repo"} + + if _, err := source.fetchLatestGitTag(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(arguments) + + if err != nil { + t.Fatal(err) + } + + repository, err := repositoryURL(source.URL) + + if err != nil { + t.Fatal(err) + } + + if string(data) != "ls-remote\n--tags\n--refs\n--sort=-version:refname\n--\n"+repository+"\n" || strings.Contains(string(data), "/INJECTED/repo") { + t.Fatalf("unexpected git arguments: %s", data) + } +} + +func TestRepositoryURL(t *testing.T) { + cases := []struct{ address, want string }{ + {"https://github.com/owner/repo/releases/download/v1/file", "https://github.com/owner/repo"}, + {"https://gitlab.com/group/nested/repo/-/archive/v1/file", "https://gitlab.com/group/nested/repo"}, + {"https://example.test/owner/repo/archive/v1.tar.gz?download=1", "https://example.test/owner/repo"}, + {"https://example.test", ""}, + {"https://example.test/owner", ""}, + {"https://example.test/owner/repo/unknown/file", ""}, + {"https://example.test/owner//archive/file", ""}, + {"https://example.test/../repo/archive/file", ""}, + {"file:///owner/repo", ""}, + {"https://user:password@example.test/owner/repo", ""}, + } + + for _, test := range cases { + t.Run(test.address, func(t *testing.T) { + got, err := repositoryURL(test.address) + + if got != test.want || (err != nil) != (test.want == "") { + t.Fatalf("repository = %q, error = %v; want %q", got, err, test.want) + } + }) + } +} From ea527d40eae664961b9021d8ae72142209718e25 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:06:00 +0000 Subject: [PATCH 2/8] fix: save environments without truncating existing files --- internal/yae/environment.go | 111 ++++++++++++++++++++++--------- internal/yae/environment_test.go | 111 +++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 internal/yae/environment_test.go diff --git a/internal/yae/environment.go b/internal/yae/environment.go index 739f069..2130430 100644 --- a/internal/yae/environment.go +++ b/internal/yae/environment.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" ) type Environment struct { @@ -11,82 +12,126 @@ type Environment struct { Sources map[string]Source } -func (s *Environment) Add(name string, d Source) error { - if s.Exists(name) { +func (environment *Environment) Add(name string, source Source) error { + if environment.Exists(name) { return fmt.Errorf("source already exists") } - (*s).Sources[name] = d + environment.Sources[name] = source return nil } -func (s *Environment) Exists(name string) bool { - _, ok := (*s).Sources[name] +func (environment *Environment) Exists(name string) bool { + _, exists := environment.Sources[name] - return ok + return exists } -func (s *Environment) Drop(url string) { - delete((*s).Sources, url) +func (environment *Environment) Drop(name string) { + delete(environment.Sources, name) } -func (s *Environment) Save(path string) error { - file, err := os.Create(path) +func (environment *Environment) Save(path string) error { + contents := make(map[string]any, len(environment.Sources)+1) + + for name, source := range environment.Sources { + contents[name] = source + } + + if environment.Schema != "" { + contents["$schema"] = environment.Schema + } + + data, err := json.MarshalIndent(contents, "", " ") if err != nil { return err } - defer file.Close() + mode := os.FileMode(0o644) + + if information, err := os.Stat(path); err == nil { + if !information.Mode().IsRegular() { + return fmt.Errorf("sources path must be a regular file") + } - sourcesData, err := json.Marshal(s.Sources) + mode = information.Mode().Perm() + path, err = filepath.EvalSymlinks(path) + + if err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } else if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("sources path is a dangling symbolic link") + } else if !os.IsNotExist(err) { + return err + } + + file, err := os.CreateTemp(filepath.Dir(path), ".yae-*") if err != nil { return err } - var jsonData map[string]json.RawMessage + defer os.Remove(file.Name()) + defer file.Close() - if err := json.Unmarshal(sourcesData, &jsonData); err != nil { + if err := file.Chmod(mode); err != nil { return err } - if s.Schema != "" { - jsonData["$schema"] = json.RawMessage(fmt.Sprintf(`"%s"`, s.Schema)) + if _, err := file.Write(append(data, '\n')); err != nil { + return err } - encoder := json.NewEncoder(file) + if err := file.Sync(); err != nil { + return err + } - encoder.SetIndent("", " ") + if err := file.Close(); err != nil { + return err + } - return encoder.Encode(jsonData) + return os.Rename(file.Name(), path) } -func (s *Environment) Load(path string) error { - file, err := os.Open(path) +func (environment *Environment) Load(path string) error { + data, err := os.ReadFile(path) if err != nil { return err } - defer file.Close() - - var rawData map[string]json.RawMessage + var contents map[string]json.RawMessage - if err := json.NewDecoder(file).Decode(&rawData); err != nil { + if err := json.Unmarshal(data, &contents); err != nil { return err } - if schema, ok := rawData["$schema"]; ok { - json.Unmarshal(schema, &s.Schema) - } + loaded := Environment{Sources: make(map[string]Source, len(contents))} - delete(rawData, "$schema") + for name, data := range contents { + if name == "$schema" { + if err := json.Unmarshal(data, &loaded.Schema); err != nil { + return fmt.Errorf("invalid $schema: %w", err) + } - if filteredData, err := json.Marshal(rawData); err != nil { - return err - } else { - return json.Unmarshal(filteredData, &s.Sources) + continue + } + + var source Source + + if err := json.Unmarshal(data, &source); err != nil { + return fmt.Errorf("source %q: %w", name, err) + } + + loaded.Sources[name] = source } + + *environment = loaded + + return nil } diff --git a/internal/yae/environment_test.go b/internal/yae/environment_test.go new file mode 100644 index 0000000..debd070 --- /dev/null +++ b/internal/yae/environment_test.go @@ -0,0 +1,111 @@ +package yae + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestEnvironmentRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "sources.json") + original := Environment{Schema: "a\"b\\c", Sources: map[string]Source{}} + + if err := original.Save(path); err != nil { + t.Fatal(err) + } + + var loaded Environment + + if err := loaded.Load(path); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(original, loaded) { + t.Fatalf("loaded %#v; want %#v", loaded, original) + } +} + +func TestSavePreservesSymlinkAndPermissions(t *testing.T) { + directory := t.TempDir() + target := filepath.Join(directory, "target.json") + link := filepath.Join(directory, "link.json") + environment := Environment{Sources: map[string]Source{}} + + if err := os.WriteFile(target, []byte("old contents"), 0o600); err != nil { + t.Fatal(err) + } + + if err := os.Symlink("target.json", link); err != nil { + t.Fatal(err) + } + + if err := environment.Save(link); err != nil { + t.Fatal(err) + } + + information, err := os.Lstat(link) + + if err != nil || information.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link was replaced: %v", err) + } + + information, err = os.Stat(target) + + if err != nil || information.Mode().Perm() != 0o600 { + t.Fatalf("permissions changed: %v", err) + } + + data, err := os.ReadFile(target) + + if err != nil || string(data) != "{}\n" { + t.Fatalf("target = %q, error = %v", data, err) + } + + entries, err := os.ReadDir(directory) + + if err != nil || len(entries) != 2 { + t.Fatalf("temporary files remain: %v, %v", entries, err) + } +} + +func TestFailedSavePreservesDestination(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "sources.json") + environment := Environment{} + + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + + marker := filepath.Join(path, "keep") + + if err := os.WriteFile(marker, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + + if err := environment.Save(path); err == nil { + t.Fatal("expected save to reject a directory") + } + + data, err := os.ReadFile(marker) + + if err != nil || string(data) != "unchanged" { + t.Fatalf("destination changed: %q, %v", data, err) + } +} + +func TestFailedLoadPreservesEnvironment(t *testing.T) { + for _, contents := range []string{`{"$schema": false}`, `{} {}`, `{"source": false}`} { + path := filepath.Join(t.TempDir(), "sources.json") + environment := Environment{Schema: "unchanged"} + + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + if err := environment.Load(path); err == nil || environment.Schema != "unchanged" { + t.Fatalf("invalid input changed environment: %#v, %v", environment, err) + } + } +} From 55061b0ff0ed8086fe9b65413c99521ae1337a3f Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:10:06 +0000 Subject: [PATCH 3/8] fix: validate sources and initialise complete hashes --- internal/commands/add.go | 60 +++------ internal/yae/environment.go | 55 +++++++- internal/yae/source.go | 58 +++++--- internal/yae/validation.go | 103 +++++++++++++++ internal/yae/validation_test.go | 227 ++++++++++++++++++++++++++++++++ yae.schema.json | 120 +++++++++++++++-- 6 files changed, 550 insertions(+), 73 deletions(-) create mode 100644 internal/yae/validation.go create mode 100644 internal/yae/validation_test.go diff --git a/internal/commands/add.go b/internal/commands/add.go index 951f119..11560d1 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -19,13 +19,6 @@ func AddFlags() []cli.Flag { Name: "type", Usage: "Source type", Required: true, - Action: func(c *cli.Context, value string) error { - if value != "binary" && value != "git" { - return fmt.Errorf("invalid source type: must be 'binary' or 'git'") - } - - return nil - }, }, &cli.StringFlag{ Name: "version", @@ -60,52 +53,37 @@ func Add(sources *yae.Environment) func(c *cli.Context) error { return fmt.Errorf("source already exists") } - source := yae.Source{ - Unpack: c.Bool("unpack"), - Type: c.String("type"), - } - version := c.String("version") + name := c.Args().Get(0) - if version != "" { - source.URLTemplate = c.Args().Get(1) - source.Version = c.String("version") - - if strings.Contains(source.URLTemplate, "{version}") { - source.URL = strings.ReplaceAll(source.URLTemplate, "{version}", source.Version) - } else { - return fmt.Errorf("version template must contain {version}") - } - } else { - source.URL = c.Args().Get(1) + if strings.TrimSpace(name) == "" || name == "$schema" { + return fmt.Errorf("source name must be non-empty and cannot be $schema") } - if source.Type == "git" && c.String("tag-predicate") != "" { - source.TagPredicate = c.String("tag-predicate") - } - - if c.String("trim-tag-prefix") != "" { - source.TrimTagPrefix = c.String("trim-tag-prefix") + source := yae.Source{ + URL: c.Args().Get(1), + Unpack: c.Bool("unpack"), + Type: c.String("type"), + Version: c.String("version"), + TagPredicate: c.String("tag-predicate"), + TrimTagPrefix: c.String("trim-tag-prefix"), + Pinned: c.Bool("pin"), + Force: c.Bool("force"), } - if c.Bool("pin") { - source.Pinned = true + if source.Version != "" { + source.URLTemplate = source.URL + source.URL = strings.ReplaceAll(source.URLTemplate, "{version}", source.Version) } - if c.Bool("force") { - if source.Pinned { - return fmt.Errorf("cannot set a source to be statically forced and pinned at the same time") - } - - source.Force = true + if err := source.Validate(); err != nil { + return err } - if sha256, err := yae.FetchSHA256(source.URL, c.Bool("unpack")); err != nil { + if err := source.RefreshHashes(); err != nil { return err - } else { - source.SHA256 = sha256 } - if err := sources.Add(c.Args().Get(0), source); err != nil { + if err := sources.Add(name, source); err != nil { return err } diff --git a/internal/yae/environment.go b/internal/yae/environment.go index 2130430..9fc5021 100644 --- a/internal/yae/environment.go +++ b/internal/yae/environment.go @@ -1,6 +1,7 @@ package yae import ( + "bytes" "encoding/json" "fmt" "os" @@ -13,10 +14,22 @@ type Environment struct { } func (environment *Environment) Add(name string, source Source) error { + if err := validateName(name); err != nil { + return err + } + + if err := source.validateStored(); err != nil { + return fmt.Errorf("source %q: %w", name, err) + } + if environment.Exists(name) { return fmt.Errorf("source already exists") } + if environment.Sources == nil { + environment.Sources = make(map[string]Source) + } + environment.Sources[name] = source return nil @@ -36,6 +49,14 @@ func (environment *Environment) Save(path string) error { contents := make(map[string]any, len(environment.Sources)+1) for name, source := range environment.Sources { + if err := validateName(name); err != nil { + return err + } + + if err := source.validateStored(); err != nil { + return fmt.Errorf("source %q: %w", name, err) + } + contents[name] = source } @@ -111,10 +132,18 @@ func (environment *Environment) Load(path string) error { return err } + if contents == nil { + return fmt.Errorf("environment must be a JSON object") + } + loaded := Environment{Sources: make(map[string]Source, len(contents))} for name, data := range contents { if name == "$schema" { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return fmt.Errorf("$schema must be a string") + } + if err := json.Unmarshal(data, &loaded.Schema); err != nil { return fmt.Errorf("invalid $schema: %w", err) } @@ -124,11 +153,33 @@ func (environment *Environment) Load(path string) error { var source Source - if err := json.Unmarshal(data, &source); err != nil { + decoder := json.NewDecoder(bytes.NewReader(data)) + + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&source); err != nil { + return fmt.Errorf("source %q: %w", name, err) + } + + var fields map[string]json.RawMessage + + if err := json.Unmarshal(data, &fields); err != nil { return fmt.Errorf("source %q: %w", name, err) } - loaded.Sources[name] = source + if _, exists := fields["unpack"]; !exists { + return fmt.Errorf("source %q: missing unpack", name) + } + + for field, value := range fields { + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("source %q: null %s", name, field) + } + } + + if err := loaded.Add(name, source); err != nil { + return err + } } *environment = loaded diff --git a/internal/yae/source.go b/internal/yae/source.go index 04efdd5..4cf016f 100644 --- a/internal/yae/source.go +++ b/internal/yae/source.go @@ -37,7 +37,7 @@ func (source *Source) Update(sources *Environment, name string, force bool, forc if source.Pinned && !forcePinned { log.Infof("skipped %s: source is pinned", name) - return updated, nil + return source.repairHash(sources, name) } if source.Type == "git" { @@ -68,29 +68,19 @@ func (source *Source) Update(sources *Environment, name string, force bool, forc } else { log.Infof("skipped %s: version remains unchanged", name) - return updated, nil + return source.repairHash(sources, name) } } log.Debugf("checking %s: sha256", name) - sha256, err := FetchSHA256(source.URL, source.Unpack) - - if err != nil { - return updated, err - } - - sriHash, err := FetchSRIHash(sha256) + previousSHA256, previousHash := source.SHA256, source.Hash - if err != nil { - return updated, err + if err := source.RefreshHashes(); err != nil { + return false, err } - if sha256 != source.SHA256 || sriHash != source.Hash || force { - log.Infof("rehashed %s: %s -> %s", name, source.SHA256, sha256) - - source.SHA256 = sha256 - source.Hash = sriHash + if source.SHA256 != previousSHA256 || source.Hash != previousHash { updated = true } @@ -182,3 +172,39 @@ func repositoryURL(address string) (string, error) { return parsed.String(), nil } + +func (source *Source) RefreshHashes() error { + sha256, err := FetchSHA256(source.URL, source.Unpack) + + if err != nil { + return err + } + + hash, err := FetchSRIHash(sha256) + + if err != nil { + return err + } + + source.SHA256 = sha256 + source.Hash = hash + + return nil +} + +func (source *Source) repairHash(environment *Environment, name string) (bool, error) { + if source.Hash != "" { + return false, nil + } + + hash, err := FetchSRIHash(source.SHA256) + + if err != nil { + return false, err + } + + source.Hash = hash + environment.Sources[name] = *source + + return true, nil +} diff --git a/internal/yae/validation.go b/internal/yae/validation.go new file mode 100644 index 0000000..ae1847d --- /dev/null +++ b/internal/yae/validation.go @@ -0,0 +1,103 @@ +package yae + +import ( + "encoding/base64" + "fmt" + "net/url" + "regexp" + "strings" +) + +var sha256Pattern = regexp.MustCompile(`^[01][0123456789abcdfghijklmnpqrsvwxyz]{51}$`) + +func (source Source) Validate() error { + if source.Type != "git" && (source.TagPredicate != "" || source.TrimTagPrefix != "" || source.Force) { + return fmt.Errorf("tag_predicate, trim_tag_prefix, and force require a git source") + } + + return source.validateConfiguration() +} + +func (source Source) validateConfiguration() error { + if source.Type != "binary" && source.Type != "git" { + return fmt.Errorf("type must be 'binary' or 'git'") + } + + address, err := url.Parse(source.URL) + + if err != nil || address.User != nil { + return fmt.Errorf("url must be valid and must not contain credentials") + } + + if address.Scheme == "file" { + if address.Path == "" || !strings.HasPrefix(address.Path, "/") || (address.Host != "" && address.Host != "localhost") { + return fmt.Errorf("file url must identify a local absolute path") + } + } else if (address.Scheme != "https" && address.Scheme != "http") || address.Hostname() == "" { + return fmt.Errorf("url must use HTTP, HTTPS, or a local file") + } + + if source.Pinned && source.Force { + return fmt.Errorf("source cannot be both pinned and forced") + } + + if source.Version != "" || source.URLTemplate != "" { + if source.Version == "" || !strings.Contains(source.URLTemplate, "{version}") { + return fmt.Errorf("version and url_template containing {version} must be supplied together") + } + + if source.URL != strings.ReplaceAll(source.URLTemplate, "{version}", source.Version) { + return fmt.Errorf("url does not match url_template and version") + } + } + + if source.Type == "git" { + if source.Version == "" { + return fmt.Errorf("git source requires version and url_template") + } + + if _, err := repositoryURL(source.URL); err != nil { + return err + } + + if _, err := regexp.Compile(source.TagPredicate); err != nil { + return fmt.Errorf("invalid tag_predicate: %w", err) + } + } + + return nil +} + +func (source Source) validateStored() error { + if err := source.validateConfiguration(); err != nil { + return err + } + + if !sha256Pattern.MatchString(source.SHA256) { + return fmt.Errorf("sha256 must be a 52-character Nix base32 SHA-256 hash") + } + + if source.Hash == "" { + return nil + } + + if !strings.HasPrefix(source.Hash, "sha256-") { + return fmt.Errorf("hash must be a SHA-256 SRI hash") + } + + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(source.Hash, "sha256-")) + + if err != nil || len(decoded) != 32 || "sha256-"+base64.StdEncoding.EncodeToString(decoded) != source.Hash { + return fmt.Errorf("hash must be a SHA-256 SRI hash") + } + + return nil +} + +func validateName(name string) error { + if strings.TrimSpace(name) == "" || name == "$schema" { + return fmt.Errorf("source name must be non-empty and cannot be $schema") + } + + return nil +} diff --git a/internal/yae/validation_test.go b/internal/yae/validation_test.go new file mode 100644 index 0000000..fe2464f --- /dev/null +++ b/internal/yae/validation_test.go @@ -0,0 +1,227 @@ +package yae + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const testSHA256 = "1wn29537l343lb0id0byk0699fj0k07m1n2d7jx2n0ssax55vhwy" +const testSRIHash = "sha256-nsNdSldaAyu6PE3YUA+YQLqUDJh+gRbBooMMekZJwvI=" + +func validSource() Source { + return Source{URL: "https://example.test/file", SHA256: testSHA256, Hash: testSRIHash, Type: "binary", Unpack: true} +} + +func fakeNix(t *testing.T) { + t.Helper() + + shell, err := exec.LookPath("sh") + + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + + for name, output := range map[string]string{"nix-prefetch-url": testSHA256, "nix": testSRIHash} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("#!"+shell+"\nprintf '%s\\n' '"+output+"'\n"), 0o755); err != nil { + t.Fatal(err) + } + } + + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestInvalidStoredSources(t *testing.T) { + cases := map[string]string{ + "null environment": `null`, + "null schema": `{"$schema":null}`, + "reserved name": `{"$schema":{}}`, + "null source": `{"sample":null}`, + } + changes := map[string]map[string]any{ + "unknown field": {"future_option": true}, + "unknown type": {"type": "other"}, + "bad sha256": {"sha256": "invalid"}, + "bad sri": {"hash": "sha256-invalid"}, + "null bool": {"unpack": nil}, + "null optional": {"pinned": nil}, + "bad url": {"url": "relative/path"}, + "credentials": {"url": "https://user:password@example.test/file"}, + "missing template": {"type": "git", "version": "v1"}, + "conflicting pin": {"pinned": true, "force": true}, + "mismatched url": {"version": "v1", "url_template": "https://example.test/{version}"}, + } + + for name, fields := range changes { + data, err := json.Marshal(validSource()) + + if err != nil { + t.Fatal(err) + } + + var source map[string]any + + if err := json.Unmarshal(data, &source); err != nil { + t.Fatal(err) + } + + for field, value := range fields { + source[field] = value + } + + data, err = json.Marshal(map[string]any{"sample": source}) + + if err != nil { + t.Fatal(err) + } + + cases[name] = string(data) + } + + for name, contents := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sources.json") + environment := Environment{Schema: "unchanged"} + + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + if err := environment.Load(path); err == nil || environment.Schema != "unchanged" { + t.Fatalf("invalid source accepted: %#v, %v", environment, err) + } + }) + } +} + +func TestAddInitialisesMapAndRejectsReservedName(t *testing.T) { + environment := Environment{} + + if err := environment.Add("$schema", validSource()); err == nil { + t.Fatal("reserved name accepted") + } + + if err := environment.Add("sample", validSource()); err != nil { + t.Fatal(err) + } + + if err := environment.Add("sample", validSource()); err == nil { + t.Fatal("duplicate name accepted") + } +} + +func TestValidationFailurePreservesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "sources.json") + environment := Environment{Sources: map[string]Source{"invalid": {}}} + + if err := os.WriteFile(path, []byte("original"), 0o600); err != nil { + t.Fatal(err) + } + + if err := environment.Save(path); err == nil { + t.Fatal("invalid source was saved") + } + + data, err := os.ReadFile(path) + + if err != nil || string(data) != "original" { + t.Fatalf("file was changed: %q, %v", data, err) + } +} + +func TestRefreshPopulatesBothHashes(t *testing.T) { + fakeNix(t) + + source := Source{URL: "https://example.test/file", Type: "binary"} + + if err := source.RefreshHashes(); err != nil { + t.Fatal(err) + } + + if source.SHA256 != testSHA256 || source.Hash != testSRIHash { + t.Fatalf("incomplete hashes: %#v", source) + } +} + +func TestUpdateRepairsLegacyHash(t *testing.T) { + for _, pinned := range []bool{false, true} { + t.Run(map[bool]string{false: "unchanged version", true: "pinned"}[pinned], func(t *testing.T) { + fakeNix(t) + fakeGit(t, "abc\trefs/tags/v1\n", false) + + source := Source{URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", Version: "v1", Type: "git", SHA256: testSHA256, Pinned: pinned} + environment := Environment{Sources: map[string]Source{"sample": source}} + updated, err := source.Update(&environment, "sample", false, false) + + if err != nil || !updated || source.Hash != testSRIHash || source.SHA256 != testSHA256 || source.Version != "v1" { + t.Fatalf("legacy repair failed: %#v, %v, %v", source, updated, err) + } + }) + } +} + +func TestMissingRequiredField(t *testing.T) { + data, err := json.Marshal(validSource()) + + if err != nil { + t.Fatal(err) + } + + path := filepath.Join(t.TempDir(), "sources.json") + contents := `{"sample":` + strings.Replace(string(data), `"unpack":true,`, "", 1) + `}` + + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + var environment Environment + + if err := environment.Load(path); err == nil { + t.Fatal("missing unpack accepted") + } +} + +func TestLegacyBinaryOptionsRoundTrip(t *testing.T) { + for _, option := range []string{"force", "trim_tag_prefix", "tag_predicate"} { + t.Run(option, func(t *testing.T) { + source := validSource() + + switch option { + case "force": + source.Force = true + case "trim_tag_prefix": + source.TrimTagPrefix = "v" + case "tag_predicate": + source.TagPredicate = "^v" + } + + if err := source.Validate(); err == nil { + t.Fatal("new binary source accepted irrelevant options") + } + + path := filepath.Join(t.TempDir(), "sources.json") + original := Environment{Sources: map[string]Source{"legacy": source}} + + if err := original.Save(path); err != nil { + t.Fatal(err) + } + + var loaded Environment + + if err := loaded.Load(path); err != nil || loaded.Sources["legacy"] != source { + t.Fatalf("legacy source changed: %#v, %v", loaded, err) + } + + loaded.Drop("legacy") + + if err := loaded.Save(path); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/yae.schema.json b/yae.schema.json index d0c1e22..d058b3c 100644 --- a/yae.schema.json +++ b/yae.schema.json @@ -1,23 +1,115 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", - "properties": { "$schema": { "type": "string" } }, + "propertyNames": { + "pattern": "\\S" + }, + "properties": { + "$schema": { + "type": "string" + } + }, "additionalProperties": { "type": "object", - "required": ["url", "sha256", "hash", "unpack", "type"], + "required": [ + "url", + "sha256", + "unpack", + "type" + ], "additionalProperties": false, "properties": { - "url": { "type": "string" }, - "sha256": { "type": "string" }, - "hash": { "type": "string" }, - "unpack": { "type": "boolean" }, - "type": { "type": "string" }, - "version": { "type": "string" }, - "url_template": { "type": "string" }, - "tag_predicate": { "type": "string" }, - "trim_tag_prefix": { "type": "string" }, - "pinned": { "type": "boolean" }, - "force": { "type": "boolean" } - } + "url": { + "type": "string", + "pattern": "^(https?://[^/]+/?.*|file://(/|localhost/).*)$" + }, + "sha256": { + "type": "string", + "pattern": "^[01][0123456789abcdfghijklmnpqrsvwxyz]{51}$" + }, + "hash": { + "type": "string", + "pattern": "^(|sha256-[A-Za-z0-9+/]{43}=)$", + "description": "New sources contain an SRI hash. Update repairs empty or missing legacy hashes." + }, + "unpack": { + "type": "boolean" + }, + "type": { + "enum": [ + "binary", + "git" + ] + }, + "version": { + "type": "string", + "minLength": 1 + }, + "url_template": { + "type": "string", + "pattern": "\\{version\\}" + }, + "tag_predicate": { + "type": "string" + }, + "trim_tag_prefix": { + "type": "string" + }, + "pinned": { + "type": "boolean" + }, + "force": { + "type": "boolean" + } + }, + "dependencies": { + "version": [ + "url_template" + ], + "url_template": [ + "version" + ] + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "git" + } + } + }, + "then": { + "required": [ + "version", + "url_template" + ], + "properties": { + "url": { + "pattern": "^https?://" + } + } + } + }, + { + "if": { + "required": [ + "pinned" + ], + "properties": { + "pinned": { + "const": true + } + } + }, + "then": { + "properties": { + "force": { + "const": false + } + } + } + } + ] } } From fbdd74a5e74d9bdf42b2af7878b5fc2c851c8ef0 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:13:23 +0000 Subject: [PATCH 4/8] refactor: simplify command execution and update orchestration --- internal/commands/add.go | 2 +- internal/commands/update.go | 83 ++++++++++++++++++------- internal/yae/command.go | 40 ++++++++++++ internal/yae/command_test.go | 107 ++++++++++++++++++++++++++++++++ internal/yae/hash.go | 45 ++++++++++++++ internal/yae/source.go | 85 +++++++------------------ internal/yae/source_test.go | 85 ++++++++++++++++++++----- internal/yae/utilities.go | 70 --------------------- internal/yae/validation.go | 10 ++- internal/yae/validation_test.go | 39 +++++------- 10 files changed, 368 insertions(+), 198 deletions(-) create mode 100644 internal/yae/command.go create mode 100644 internal/yae/command_test.go create mode 100644 internal/yae/hash.go delete mode 100644 internal/yae/utilities.go diff --git a/internal/commands/add.go b/internal/commands/add.go index 11560d1..21db931 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -79,7 +79,7 @@ func Add(sources *yae.Environment) func(c *cli.Context) error { return err } - if err := source.RefreshHashes(); err != nil { + if err := source.RefreshHashes(c.Context); err != nil { return err } diff --git a/internal/commands/update.go b/internal/commands/update.go index 5889d3a..d3eba65 100644 --- a/internal/commands/update.go +++ b/internal/commands/update.go @@ -2,8 +2,11 @@ package commands import ( "fmt" + "sort" + "strings" "github.com/Fuwn/yae/internal/yae" + "github.com/charmbracelet/log" "github.com/urfave/cli/v2" ) @@ -11,7 +14,7 @@ func UpdateFlags() []cli.Flag { return []cli.Flag{ &cli.BoolFlag{ Name: "output-updated-list", - Usage: "Output a newline-seperated list of updated sources, regardless of silent mode", + Usage: "Output a newline-separated list of updated sources, regardless of silent mode", }, &cli.BoolFlag{ Name: "output-formatted-updated-list", @@ -30,43 +33,79 @@ func UpdateFlags() []cli.Flag { func Update(sources *yae.Environment) func(c *cli.Context) error { return func(c *cli.Context) error { + if c.Args().Len() > 1 { + return fmt.Errorf("update accepts at most one source name") + } + + names := c.Args().Slice() + + if len(names) == 0 { + for name := range sources.Sources { + names = append(names, name) + } + } else if !sources.Exists(names[0]) { + return fmt.Errorf("source %q does not exist", names[0]) + } + + sort.Strings(names) + updates := []string{} - force := c.Bool("force-hashed") - forcePinned := c.Bool("force-pinned") + pending := yae.Environment{Schema: sources.Schema, Sources: make(map[string]yae.Source, len(sources.Sources))} - if c.Args().Len() == 0 { - for name, source := range sources.Sources { - if updated, err := source.Update(sources, name, force, forcePinned); err != nil { - return err - } else if updated { - updates = append(updates, name) - } + for name, source := range sources.Sources { + pending.Sources[name] = source + } + + for _, name := range names { + log.Infof("checking %s", name) + + source := sources.Sources[name] + updated, err := source.Update(c.Context, c.Bool("force-hashed"), c.Bool("force-pinned")) + + if err != nil { + return fmt.Errorf("source %q: %w", name, err) } - } else { - name := c.Args().Get(0) - source := (*sources).Sources[name] - if updated, err := source.Update(sources, name, force, forcePinned); err != nil { - return err - } else if updated { + if updated != source { + pending.Sources[name] = updated updates = append(updates, name) } } - if len(updates) > 0 && !c.Bool("dry-run") { - if err := sources.Save(c.String("sources")); err != nil { - return err + if len(updates) > 0 { + if c.Bool("dry-run") { + log.Infof("would update %s", strings.Join(updates, ", ")) + } else { + if err := pending.Save(c.String("sources")); err != nil { + return err + } + + *sources = pending + + log.Infof("updated %s", strings.Join(updates, ", ")) } } if c.Bool("output-updated-list") { - for _, update := range updates { - fmt.Println(update) + for _, name := range updates { + fmt.Fprintln(c.App.Writer, name) } } else if c.Bool("output-formatted-updated-list") { - fmt.Println(yae.Lister(updates)) + fmt.Fprintln(c.App.Writer, formatNames(updates)) } return nil } } + +func formatNames(names []string) string { + if len(names) < 2 { + return strings.Join(names, "") + } + + if len(names) == 2 { + return strings.Join(names, " & ") + } + + return strings.Join(names[:len(names)-1], ", ") + ", & " + names[len(names)-1] +} diff --git a/internal/yae/command.go b/internal/yae/command.go new file mode 100644 index 0000000..12d6887 --- /dev/null +++ b/internal/yae/command.go @@ -0,0 +1,40 @@ +package yae + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/charmbracelet/log" +) + +func command(context context.Context, name string, arguments ...string) (string, error) { + process := exec.CommandContext(context, name, arguments...) + + var stderr bytes.Buffer + + process.Stderr = &stderr + process.WaitDelay = time.Second + + if name == "git" { + process.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + } + + log.Debugf("running %s", name) + + output, err := process.Output() + + if context.Err() != nil { + return "", fmt.Errorf("%s: %w", name, context.Err()) + } + + if err != nil { + return "", fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(stderr.String())) + } + + return string(output), nil +} diff --git a/internal/yae/command_test.go b/internal/yae/command_test.go new file mode 100644 index 0000000..ff454ef --- /dev/null +++ b/internal/yae/command_test.go @@ -0,0 +1,107 @@ +package yae + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func fakeCommand(t *testing.T, name string, body string) { + t.Helper() + + shell, err := exec.LookPath("sh") + + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + + if err := os.WriteFile(filepath.Join(directory, name), []byte("#!"+shell+"\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestCommandFailureIncludesDiagnostics(t *testing.T) { + fakeCommand(t, "git", "printf 'remote unavailable' >&2\nexit 23") + + _, err := command(context.Background(), "git", "ls-remote") + + if err == nil || !strings.Contains(err.Error(), "git") || !strings.Contains(err.Error(), "remote unavailable") || !strings.Contains(err.Error(), "23") { + t.Fatalf("missing diagnostics: %v", err) + } +} + +func TestCommandCancellation(t *testing.T) { + fakeCommand(t, "git", "while :; do :; done") + + context, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + + defer cancel() + + started := time.Now() + _, err := command(context, "git") + + if !errors.Is(err, context.Err()) || time.Since(started) > 5*time.Second { + t.Fatalf("command did not cancel promptly: %v", err) + } +} + +func TestHashOutputValidation(t *testing.T) { + for _, output := range []string{"", "invalid", testSHA256 + "\nextra", testSHA256, testSHA256 + "\n"} { + t.Run(output, func(t *testing.T) { + fakeCommand(t, "nix-prefetch-url", "printf '%s' \"$YAE_TEST_HASH\"") + t.Setenv("YAE_TEST_HASH", output) + + hash, err := fetchSHA256(context.Background(), "https://example.test/file", false) + valid := strings.TrimSpace(output) == testSHA256 + + if (err == nil) != valid || (valid && hash != testSHA256) { + t.Fatalf("hash = %q, error = %v", hash, err) + } + }) + } +} + +func TestFailedHashConversionLeavesSourceUnchanged(t *testing.T) { + fakeNix(t) + fakeCommand(t, "nix", "printf 'invalid'") + + source := validSource() + original := source + + if err := source.RefreshHashes(context.Background()); err == nil || source != original { + t.Fatalf("failed conversion changed source: %#v, %v", source, err) + } +} + +func TestFailedUpdateLeavesSourceUnchanged(t *testing.T) { + fakeGit(t, "abc\trefs/tags/v2\n", false) + fakeCommand(t, "nix-prefetch-url", "exit 1") + + source := Source{Type: "git", Version: "v1", URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", SHA256: testSHA256, Hash: testSRIHash} + original := source + + if _, err := source.Update(context.Background(), false, false); err == nil || source != original { + t.Fatalf("failed update changed source: %#v, %v", source, err) + } +} + +func TestForcedRehashDoesNotInventAChange(t *testing.T) { + fakeGit(t, "abc\trefs/tags/v1\n", false) + fakeNix(t) + + source := Source{Type: "git", Version: "v1", URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", SHA256: testSHA256, Hash: testSRIHash} + updated, err := source.Update(context.Background(), true, false) + + if err != nil || updated != source { + t.Fatalf("unchanged content reported as changed: %#v, %v", updated, err) + } +} diff --git a/internal/yae/hash.go b/internal/yae/hash.go new file mode 100644 index 0000000..84a275e --- /dev/null +++ b/internal/yae/hash.go @@ -0,0 +1,45 @@ +package yae + +import ( + "context" + "fmt" + "strings" +) + +func fetchSHA256(context context.Context, address string, unpack bool) (string, error) { + arguments := []string{"--type", "sha256", address} + + if unpack { + arguments = append([]string{"--unpack"}, arguments...) + } + + output, err := command(context, "nix-prefetch-url", arguments...) + + if err != nil { + return "", err + } + + hash := strings.TrimSpace(output) + + if !sha256Pattern.MatchString(hash) { + return "", fmt.Errorf("nix-prefetch-url returned an invalid SHA-256 hash") + } + + return hash, nil +} + +func fetchSRIHash(context context.Context, sha256 string) (string, error) { + output, err := command(context, "nix", "hash", "convert", "--hash-algo", "sha256", "--from", "nix32", "--to", "sri", sha256) + + if err != nil { + return "", err + } + + hash := strings.TrimSpace(output) + + if err := validateSRIHash(hash); err != nil { + return "", fmt.Errorf("nix hash convert: %w", err) + } + + return hash, nil +} diff --git a/internal/yae/source.go b/internal/yae/source.go index 4cf016f..4a86726 100644 --- a/internal/yae/source.go +++ b/internal/yae/source.go @@ -1,12 +1,11 @@ package yae import ( + "context" "fmt" "net/url" "regexp" "strings" - - "github.com/charmbracelet/log" ) type Source struct { @@ -23,73 +22,34 @@ type Source struct { Force bool `json:"force,omitempty"` } -func (source *Source) Update(sources *Environment, name string, force bool, forcePinned bool) (bool, error) { - log.Infof("checking %s", name) - - updated := false - - if !sources.Exists(name) { - log.Warnf("skipped %s: source does not exist", name) - - return updated, nil - } - +func (source Source) Update(context context.Context, forceHash bool, forcePinned bool) (Source, error) { if source.Pinned && !forcePinned { - log.Infof("skipped %s: source is pinned", name) - - return source.repairHash(sources, name) + return source.repairHash(context) } if source.Type == "git" { - log.Debugf("checking %s: remote git tag", name) - - tag, err := source.fetchLatestGitTag() + tag, err := source.fetchLatestGitTag(context) if err != nil { - return updated, err + return Source{}, err } - if tag != source.Version || force || source.Force { - if tag != source.Version { - log.Infof("bumped %s: %s -> %s", name, source.Version, tag) - } - - if tag != source.Version { - updated = true - } - - source.Version = tag - - if strings.Contains(source.URLTemplate, "{version}") { - source.URL = strings.ReplaceAll(source.URLTemplate, "{version}", source.Version) - - log.Debugf("patched %s: substituted url template", name) - } - } else { - log.Infof("skipped %s: version remains unchanged", name) - - return source.repairHash(sources, name) + if tag == source.Version && !forceHash && !source.Force { + return source.repairHash(context) } - } - - log.Debugf("checking %s: sha256", name) - previousSHA256, previousHash := source.SHA256, source.Hash - - if err := source.RefreshHashes(); err != nil { - return false, err + source.Version = tag + source.URL = strings.ReplaceAll(source.URLTemplate, "{version}", tag) } - if source.SHA256 != previousSHA256 || source.Hash != previousHash { - updated = true + if err := source.RefreshHashes(context); err != nil { + return Source{}, err } - (*sources).Sources[name] = *source - - return updated, nil + return source, nil } -func (source *Source) fetchLatestGitTag() (string, error) { +func (source *Source) fetchLatestGitTag(context context.Context) (string, error) { if source.Type != "git" { return "", fmt.Errorf("source is not a git repository") } @@ -106,7 +66,7 @@ func (source *Source) fetchLatestGitTag() (string, error) { return "", fmt.Errorf("invalid tag_predicate: %w", err) } - output, err := command("git", false, "ls-remote", "--tags", "--refs", "--sort=-version:refname", "--", repository) + output, err := command(context, "git", "ls-remote", "--tags", "--refs", "--sort=-version:refname", "--", repository) if err != nil { return "", fmt.Errorf("list remote tags: %w", err) @@ -173,14 +133,14 @@ func repositoryURL(address string) (string, error) { return parsed.String(), nil } -func (source *Source) RefreshHashes() error { - sha256, err := FetchSHA256(source.URL, source.Unpack) +func (source *Source) RefreshHashes(context context.Context) error { + sha256, err := fetchSHA256(context, source.URL, source.Unpack) if err != nil { return err } - hash, err := FetchSRIHash(sha256) + hash, err := fetchSRIHash(context, sha256) if err != nil { return err @@ -192,19 +152,18 @@ func (source *Source) RefreshHashes() error { return nil } -func (source *Source) repairHash(environment *Environment, name string) (bool, error) { +func (source Source) repairHash(context context.Context) (Source, error) { if source.Hash != "" { - return false, nil + return source, nil } - hash, err := FetchSRIHash(source.SHA256) + hash, err := fetchSRIHash(context, source.SHA256) if err != nil { - return false, err + return Source{}, err } source.Hash = hash - environment.Sources[name] = *source - return true, nil + return source, nil } diff --git a/internal/yae/source_test.go b/internal/yae/source_test.go index d9c9f53..93116d7 100644 --- a/internal/yae/source_test.go +++ b/internal/yae/source_test.go @@ -1,8 +1,8 @@ package yae import ( + "context" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -11,25 +11,14 @@ import ( func fakeGit(t *testing.T, output string, failure bool) string { t.Helper() - shell, err := exec.LookPath("sh") - - if err != nil { - t.Fatal(err) - } - - directory := t.TempDir() - arguments := filepath.Join(directory, "arguments") - script := "#!" + shell + "\nprintf '%s\\n' \"$@\" > \"$YAE_TEST_ARGUMENTS\"\nprintf '%s' \"$YAE_TEST_TAGS\"\n" + arguments := filepath.Join(t.TempDir(), "arguments") + script := "printf '%s\\n' \"$@\" > \"$YAE_TEST_ARGUMENTS\"\nprintf '%s' \"$YAE_TEST_TAGS\"\n" if failure { script += "exit 23\n" } - if err := os.WriteFile(filepath.Join(directory, "git"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - - t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) + fakeCommand(t, "git", script) t.Setenv("YAE_TEST_ARGUMENTS", arguments) t.Setenv("YAE_TEST_TAGS", output) @@ -61,7 +50,7 @@ func TestLatestGitTag(t *testing.T) { fakeGit(t, test.output, test.failure) source := Source{Type: "git", URL: "https://example.test/owner/repo/archive/v0.tar.gz", TagPredicate: test.predicate, TrimTagPrefix: test.prefix} - version, err := source.fetchLatestGitTag() + version, err := source.fetchLatestGitTag(context.Background()) if test.want == "" { if err == nil { @@ -82,7 +71,7 @@ func TestGitURLIsAnArgument(t *testing.T) { arguments := fakeGit(t, "abc\trefs/tags/v1\n", false) source := Source{Type: "git", URL: "https://example.test/$(printf${IFS}INJECTED)/repo"} - if _, err := source.fetchLatestGitTag(); err != nil { + if _, err := source.fetchLatestGitTag(context.Background()); err != nil { t.Fatal(err) } @@ -127,3 +116,65 @@ func TestRepositoryURL(t *testing.T) { }) } } + +func TestUpdatePolicies(t *testing.T) { + cases := []struct { + name string + kind string + version string + pinned bool + persistent bool + forceHash bool + forcePinned bool + fetch bool + wantVersion string + }{ + {name: "floating URL", kind: "binary", fetch: true}, + {name: "pinned URL", kind: "binary", pinned: true}, + {name: "forced pinned URL", kind: "binary", pinned: true, forcePinned: true, fetch: true}, + {name: "unchanged tag", kind: "git", version: "v2", wantVersion: "v2"}, + {name: "new tag", kind: "git", version: "v1", fetch: true, wantVersion: "v2"}, + {name: "persistent rehash", kind: "git", version: "v2", persistent: true, fetch: true, wantVersion: "v2"}, + {name: "requested rehash", kind: "git", version: "v2", forceHash: true, fetch: true, wantVersion: "v2"}, + {name: "pin overrides rehash", kind: "git", version: "v1", pinned: true, forceHash: true, wantVersion: "v1"}, + {name: "override pin", kind: "git", version: "v1", pinned: true, forcePinned: true, fetch: true, wantVersion: "v2"}, + {name: "pin override alone skips unchanged tag", kind: "git", version: "v2", pinned: true, forcePinned: true, wantVersion: "v2"}, + {name: "both overrides", kind: "git", version: "v2", pinned: true, forcePinned: true, forceHash: true, fetch: true, wantVersion: "v2"}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + fakeGit(t, "abc\trefs/tags/v2\n", false) + fakeNix(t) + + marker := filepath.Join(t.TempDir(), "prefetched") + + t.Setenv("YAE_TEST_PREFETCH", marker) + fakeCommand(t, "nix-prefetch-url", "printf fetched > \"$YAE_TEST_PREFETCH\"\nprintf '%s\\n' '"+testSHA256+"'") + + source := validSource() + + source.Type = test.kind + source.Pinned = test.pinned + source.Force = test.persistent + + if test.kind == "git" { + source.Version = test.version + source.URLTemplate = "https://example.test/owner/repo/archive/{version}" + source.URL = strings.ReplaceAll(source.URLTemplate, "{version}", test.version) + } + + updated, err := source.Update(context.Background(), test.forceHash, test.forcePinned) + + if err != nil || updated.Version != test.wantVersion { + t.Fatalf("updated = %#v, error = %v", updated, err) + } + + _, err = os.Stat(marker) + + if (err == nil) != test.fetch { + t.Fatalf("fetch occurred = %v; want %v", err == nil, test.fetch) + } + }) + } +} diff --git a/internal/yae/utilities.go b/internal/yae/utilities.go deleted file mode 100644 index f6fc32c..0000000 --- a/internal/yae/utilities.go +++ /dev/null @@ -1,70 +0,0 @@ -package yae - -import ( - "fmt" - "os" - "os/exec" - "strings" -) - -func FetchSHA256(url string, unpack bool) (string, error) { - arguments := []string{"--type", "sha256", url} - - if unpack { - arguments = append([]string{"--unpack"}, arguments...) - } - - output, err := command("nix-prefetch-url", false, arguments...) - - if err != nil { - return "", err - } - - lines := strings.Split(output, "\n") - - return strings.Trim(lines[len(lines)-2], "\n"), nil -} - -func FetchSRIHash(sha256 string) (string, error) { - output, err := command("nix", false, "hash", "convert", "--hash-algo", "sha256", "--from", "nix32", sha256) - - if err != nil { - return "", err - } - - return strings.Trim(output, "\n"), nil -} - -func command(name string, show bool, args ...string) (string, error) { - executable, err := exec.LookPath(name) - - if err != nil { - return "", fmt.Errorf("command not found: %s", name) - } - - var out []byte - - if show { - cmd := exec.Command(executable, args...) - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - out, err = cmd.Output() - } else { - cmd := exec.Command(executable, args...) - out, err = cmd.Output() - } - - return string(out), err -} - -func Lister(items []string) string { - if len(items) == 0 { - return "" - } else if len(items) == 1 { - return items[0] - } else if len(items) == 2 { - return fmt.Sprintf("%s & %s", items[0], items[1]) - } - - return fmt.Sprintf("%s, & %s", strings.Join(items[:len(items)-1], ", "), items[len(items)-1]) -} diff --git a/internal/yae/validation.go b/internal/yae/validation.go index ae1847d..6513b4b 100644 --- a/internal/yae/validation.go +++ b/internal/yae/validation.go @@ -81,13 +81,17 @@ func (source Source) validateStored() error { return nil } - if !strings.HasPrefix(source.Hash, "sha256-") { + return validateSRIHash(source.Hash) +} + +func validateSRIHash(hash string) error { + if !strings.HasPrefix(hash, "sha256-") { return fmt.Errorf("hash must be a SHA-256 SRI hash") } - decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(source.Hash, "sha256-")) + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(hash, "sha256-")) - if err != nil || len(decoded) != 32 || "sha256-"+base64.StdEncoding.EncodeToString(decoded) != source.Hash { + if err != nil || len(decoded) != 32 || "sha256-"+base64.StdEncoding.EncodeToString(decoded) != hash { return fmt.Errorf("hash must be a SHA-256 SRI hash") } diff --git a/internal/yae/validation_test.go b/internal/yae/validation_test.go index fe2464f..4d99eb2 100644 --- a/internal/yae/validation_test.go +++ b/internal/yae/validation_test.go @@ -1,9 +1,9 @@ package yae import ( + "context" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -19,21 +19,9 @@ func validSource() Source { func fakeNix(t *testing.T) { t.Helper() - shell, err := exec.LookPath("sh") - - if err != nil { - t.Fatal(err) - } - - directory := t.TempDir() - for name, output := range map[string]string{"nix-prefetch-url": testSHA256, "nix": testSRIHash} { - if err := os.WriteFile(filepath.Join(directory, name), []byte("#!"+shell+"\nprintf '%s\\n' '"+output+"'\n"), 0o755); err != nil { - t.Fatal(err) - } + fakeCommand(t, name, "printf '%s\\n' '"+output+"'") } - - t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) } func TestInvalidStoredSources(t *testing.T) { @@ -99,7 +87,7 @@ func TestInvalidStoredSources(t *testing.T) { } } -func TestAddInitialisesMapAndRejectsReservedName(t *testing.T) { +func TestAddInitializesMapAndRejectsReservedName(t *testing.T) { environment := Environment{} if err := environment.Add("$schema", validSource()); err == nil { @@ -139,7 +127,7 @@ func TestRefreshPopulatesBothHashes(t *testing.T) { source := Source{URL: "https://example.test/file", Type: "binary"} - if err := source.RefreshHashes(); err != nil { + if err := source.RefreshHashes(context.Background()); err != nil { t.Fatal(err) } @@ -149,16 +137,23 @@ func TestRefreshPopulatesBothHashes(t *testing.T) { } func TestUpdateRepairsLegacyHash(t *testing.T) { - for _, pinned := range []bool{false, true} { - t.Run(map[bool]string{false: "unchanged version", true: "pinned"}[pinned], func(t *testing.T) { + cases := []struct { + name string + pinned bool + }{ + {name: "unchanged version"}, + {name: "pinned", pinned: true}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { fakeNix(t) fakeGit(t, "abc\trefs/tags/v1\n", false) - source := Source{URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", Version: "v1", Type: "git", SHA256: testSHA256, Pinned: pinned} - environment := Environment{Sources: map[string]Source{"sample": source}} - updated, err := source.Update(&environment, "sample", false, false) + source := Source{URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", Version: "v1", Type: "git", SHA256: testSHA256, Pinned: test.pinned} + updated, err := source.Update(context.Background(), false, false) - if err != nil || !updated || source.Hash != testSRIHash || source.SHA256 != testSHA256 || source.Version != "v1" { + if err != nil || updated.Hash != testSRIHash || updated.SHA256 != testSHA256 || updated.Version != "v1" { t.Fatalf("legacy repair failed: %#v, %v, %v", source, updated, err) } }) From 6de1565de5b27a6737df4f1801befdf4179283a4 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:18:03 +0000 Subject: [PATCH 5/8] fix: enforce CLI contracts and correct floating-source examples --- README.md | 21 ++- examples/README.md | 10 +- examples/nixpkgs/yae.json | 4 +- internal/commands/add.go | 44 +++--- internal/commands/drop.go | 18 +-- internal/commands/init.go | 22 ++- internal/commands/update.go | 22 +-- internal/yae/environment.go | 20 ++- internal/yae/source_test.go | 39 +++++ yae.go | 69 +++++---- yae_test.go | 295 ++++++++++++++++++++++++++++++++++++ 11 files changed, 456 insertions(+), 108 deletions(-) create mode 100644 yae_test.go diff --git a/README.md b/README.md index 8a1830e..8c8924b 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,8 @@ which functions similar to [niv](https://github.com/nmattia/niv/) and [`npins`]( mainline sources. This requires additional upgrade commands in the CLI and more effort to maintain. A Yae environment is a single file and can be placed anywhere and read just as simply. - 2. Yae has a simple and coherent source tree. niv has a total of 10000 LOC - (lines of code), `npins` sits at almost 6000 LOC flat, and Yae stands at just - shy of 1500 LOC when looking at all files. Yae's core source code itself sits - at just 462 LOC, which is much, **much** smaller than that of niv and `npins`' - core trees. This is all to say that Yae implements everything needed to functionally - replace niv and `npins` in any workflow, and in much more efficient and concise - codebase. + 2. Yae has a small, coherent source tree focused on fetching sources and + updating their versions and hashes. 3. Yae is simple by nature in design and usage philosophy. niv and `npins` are great, but are far too ~~overkill~~ overengineered for me @@ -62,7 +57,7 @@ yae add \ zen-browser-twilight-bin \ 'https://github.com/zen-browser/desktop/releases/download/{version}/zen.linux-specific.tar.bz2' -# Adds a Yae dependency named `zen-browser-bin` pinned at tag `1.0.1-a.7` +# This command adds a Yae dependency named `zen-browser-bin` starting at tag `1.0.1-a.7`. yae add \ --type git \ --version 1.0.1-a.7 \ @@ -70,8 +65,8 @@ yae add \ zen-browser-bin \ 'https://github.com/zen-browser/desktop/releases/download/{version}/zen.linux-specific.tar.bz2' -# Adds a Yae dependency named `yaak` pinned at tag `2024.10.1` with tag trimming -# for updates +# This command adds a Yae dependency named `yaak` starting at tag `2024.10.1` with tag +# trimming for updates. yae add \ --type git \ --unpack=false \ @@ -89,6 +84,8 @@ yae update yae update zen-browser-twilight-bin ``` +Use `--pin` when adding a source to prevent version and content updates. + ## Installation You can either install Yae through the flake that this repository exposes or @@ -201,8 +198,8 @@ COMMANDS: GLOBAL OPTIONS: --sources value Sources path (default: "./yae.json") --debug Enable debug output (default: false) - --silent Silence log output (default: false) - --dry-run Prevents writing to disk (default: false) + --silent Only log errors (default: false) + --dry-run Preview changes without saving the sources file (downloads may still populate the Nix store) (default: false) --help, -h show help COPYRIGHT: diff --git a/examples/README.md b/examples/README.md index 513f3b0..7bd9918 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,12 @@ # Examples -If Nixpkgs ever goes out of date in these examples, just run `yae update`! +Run `yae update` inside an example directory to refresh its Nixpkgs hash. +The source uses `type: binary` because the archive URL follows a moving branch; +Yae rehashes that URL instead of discovering release tags. Both examples share +one `yae.json` through a symbolic link. + +A moving URL may no longer match the saved hash when downloaded on a fresh +machine. Run the update before evaluating the example in that case. ## [Nixpkgs](https://github.com/Fuwn/yae/tree/main/examples/nixpkgs) @@ -11,7 +17,7 @@ Nixpkgs as a flake output. Note that the flake has no inputs. This is because Yae directly manages the Nixpkgs source. -This example is extremely useful and is intended be adapted to suite the specific +This example is extremely useful and is intended to be adapted to suit the specific needs of flake-less Nix configurations, like classic Nix shells and flake-less system configurations. diff --git a/examples/nixpkgs/yae.json b/examples/nixpkgs/yae.json index eb5ff31..959e508 100644 --- a/examples/nixpkgs/yae.json +++ b/examples/nixpkgs/yae.json @@ -4,8 +4,6 @@ "sha256": "1wn29537l343lb0id0byk0699fj0k07m1n2d7jx2n0ssax55vhwy", "hash": "sha256-nsNdSldaAyu6PE3YUA+YQLqUDJh+gRbBooMMekZJwvI=", "unpack": true, - "type": "git", - "version": "nixos-unstable", - "url_template": "https://github.com/NixOS/nixpkgs/archive/{version}.tar.gz" + "type": "binary" } } diff --git a/internal/commands/add.go b/internal/commands/add.go index 21db931..945bf4a 100644 --- a/internal/commands/add.go +++ b/internal/commands/add.go @@ -43,31 +43,27 @@ func AddFlags() []cli.Flag { } } -func Add(sources *yae.Environment) func(c *cli.Context) error { - return func(c *cli.Context) error { - if c.Args().Len() != 2 { +func Add(sources *yae.Environment) func(context *cli.Context) error { + return func(context *cli.Context) error { + if context.Args().Len() != 2 { return fmt.Errorf("invalid number of arguments") } - if sources.Exists(c.Args().Get(0)) { - return fmt.Errorf("source already exists") - } - - name := c.Args().Get(0) + name := context.Args().Get(0) - if strings.TrimSpace(name) == "" || name == "$schema" { - return fmt.Errorf("source name must be non-empty and cannot be $schema") + if err := sources.CheckNewName(name); err != nil { + return err } source := yae.Source{ - URL: c.Args().Get(1), - Unpack: c.Bool("unpack"), - Type: c.String("type"), - Version: c.String("version"), - TagPredicate: c.String("tag-predicate"), - TrimTagPrefix: c.String("trim-tag-prefix"), - Pinned: c.Bool("pin"), - Force: c.Bool("force"), + URL: context.Args().Get(1), + Unpack: context.Bool("unpack"), + Type: context.String("type"), + Version: context.String("version"), + TagPredicate: context.String("tag-predicate"), + TrimTagPrefix: context.String("trim-tag-prefix"), + Pinned: context.Bool("pin"), + Force: context.Bool("force"), } if source.Version != "" { @@ -79,18 +75,18 @@ func Add(sources *yae.Environment) func(c *cli.Context) error { return err } - if err := source.RefreshHashes(c.Context); err != nil { + if err := source.RefreshHashes(context.Context); err != nil { return err } - if err := sources.Add(name, source); err != nil { - return err + if context.Bool("dry-run") { + return nil } - if c.Bool("dry-run") { - return nil + if err := sources.Add(name, source); err != nil { + return err } - return sources.Save(c.String("sources")) + return sources.Save(context.String("sources")) } } diff --git a/internal/commands/drop.go b/internal/commands/drop.go index 788b62b..b334f74 100644 --- a/internal/commands/drop.go +++ b/internal/commands/drop.go @@ -7,22 +7,22 @@ import ( "github.com/urfave/cli/v2" ) -func Drop(sources *yae.Environment) func(c *cli.Context) error { - return func(c *cli.Context) error { - if c.Args().Len() == 0 { - return fmt.Errorf("invalid number of arguments") +func Drop(sources *yae.Environment) func(context *cli.Context) error { + return func(context *cli.Context) error { + if context.Args().Len() != 1 { + return fmt.Errorf("drop requires exactly one source name") } - if !sources.Exists(c.Args().Get(0)) { + if !sources.Exists(context.Args().Get(0)) { return fmt.Errorf("source does not exist") } - sources.Drop(c.Args().Get(0)) - - if c.Bool("dry-run") { + if context.Bool("dry-run") { return nil } - return sources.Save(c.String("sources")) + sources.Drop(context.Args().Get(0)) + + return sources.Save(context.String("sources")) } } diff --git a/internal/commands/init.go b/internal/commands/init.go index 94433ab..423017e 100644 --- a/internal/commands/init.go +++ b/internal/commands/init.go @@ -8,19 +8,25 @@ import ( "github.com/urfave/cli/v2" ) -func Init(sources *yae.Environment) func(c *cli.Context) error { - return func(c *cli.Context) error { - if _, err := os.Stat(c.String("sources")); err == nil { - return fmt.Errorf("sources file already exists") +func Init(sources *yae.Environment) func(context *cli.Context) error { + return func(context *cli.Context) error { + if context.Args().Len() != 0 { + return fmt.Errorf("init does not accept arguments") } - sources.Sources = make(map[string]yae.Source) - sources.Schema = "https://raw.githubusercontent.com/Fuwn/yae/refs/heads/main/yae.schema.json" + if _, err := os.Lstat(context.String("sources")); err == nil { + return fmt.Errorf("sources file already exists") + } else if !os.IsNotExist(err) { + return err + } - if c.Bool("dry-run") { + if context.Bool("dry-run") { return nil } - return sources.Save(c.String("sources")) + sources.Sources = make(map[string]yae.Source) + sources.Schema = "https://raw.githubusercontent.com/Fuwn/yae/refs/heads/main/yae.schema.json" + + return sources.Save(context.String("sources")) } } diff --git a/internal/commands/update.go b/internal/commands/update.go index d3eba65..52e759b 100644 --- a/internal/commands/update.go +++ b/internal/commands/update.go @@ -31,13 +31,13 @@ func UpdateFlags() []cli.Flag { } } -func Update(sources *yae.Environment) func(c *cli.Context) error { - return func(c *cli.Context) error { - if c.Args().Len() > 1 { +func Update(sources *yae.Environment) func(context *cli.Context) error { + return func(context *cli.Context) error { + if context.Args().Len() > 1 { return fmt.Errorf("update accepts at most one source name") } - names := c.Args().Slice() + names := context.Args().Slice() if len(names) == 0 { for name := range sources.Sources { @@ -60,7 +60,7 @@ func Update(sources *yae.Environment) func(c *cli.Context) error { log.Infof("checking %s", name) source := sources.Sources[name] - updated, err := source.Update(c.Context, c.Bool("force-hashed"), c.Bool("force-pinned")) + updated, err := source.Update(context.Context, context.Bool("force-hashed"), context.Bool("force-pinned")) if err != nil { return fmt.Errorf("source %q: %w", name, err) @@ -73,10 +73,10 @@ func Update(sources *yae.Environment) func(c *cli.Context) error { } if len(updates) > 0 { - if c.Bool("dry-run") { + if context.Bool("dry-run") { log.Infof("would update %s", strings.Join(updates, ", ")) } else { - if err := pending.Save(c.String("sources")); err != nil { + if err := pending.Save(context.String("sources")); err != nil { return err } @@ -86,12 +86,12 @@ func Update(sources *yae.Environment) func(c *cli.Context) error { } } - if c.Bool("output-updated-list") { + if context.Bool("output-updated-list") { for _, name := range updates { - fmt.Fprintln(c.App.Writer, name) + fmt.Fprintln(context.App.Writer, name) } - } else if c.Bool("output-formatted-updated-list") { - fmt.Fprintln(c.App.Writer, formatNames(updates)) + } else if context.Bool("output-formatted-updated-list") { + fmt.Fprintln(context.App.Writer, formatNames(updates)) } return nil diff --git a/internal/yae/environment.go b/internal/yae/environment.go index 9fc5021..3c2e072 100644 --- a/internal/yae/environment.go +++ b/internal/yae/environment.go @@ -13,17 +13,25 @@ type Environment struct { Sources map[string]Source } -func (environment *Environment) Add(name string, source Source) error { +func (environment Environment) CheckNewName(name string) error { if err := validateName(name); err != nil { return err } - if err := source.validateStored(); err != nil { - return fmt.Errorf("source %q: %w", name, err) + if environment.Exists(name) { + return fmt.Errorf("source %q already exists", name) } - if environment.Exists(name) { - return fmt.Errorf("source already exists") + return nil +} + +func (environment *Environment) Add(name string, source Source) error { + if err := environment.CheckNewName(name); err != nil { + return err + } + + if err := source.validateStored(); err != nil { + return fmt.Errorf("source %q: %w", name, err) } if environment.Sources == nil { @@ -35,7 +43,7 @@ func (environment *Environment) Add(name string, source Source) error { return nil } -func (environment *Environment) Exists(name string) bool { +func (environment Environment) Exists(name string) bool { _, exists := environment.Sources[name] return exists diff --git a/internal/yae/source_test.go b/internal/yae/source_test.go index 93116d7..92deef2 100644 --- a/internal/yae/source_test.go +++ b/internal/yae/source_test.go @@ -3,6 +3,7 @@ package yae import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -117,6 +118,44 @@ func TestRepositoryURL(t *testing.T) { } } +func TestGitVersionOrderingWithLocalRepository(t *testing.T) { + repository := t.TempDir() + runGit := func(input string, arguments ...string) string { + t.Helper() + + process := exec.Command("git", append([]string{"-C", repository}, arguments...)...) + + process.Stdin = strings.NewReader(input) + + output, err := process.CombinedOutput() + + if err != nil { + t.Fatalf("git %v: %s, %v", arguments, output, err) + } + + return strings.TrimSpace(string(output)) + } + + runGit("", "init", "--bare") + + tree := runGit("", "mktree") + commit := runGit("", "-c", "user.name=Yae Test", "-c", "user.email=yae@example.test", "commit-tree", tree, "-m", "fixture") + + runGit("", "update-ref", "refs/tags/v2", commit) + runGit("", "update-ref", "refs/tags/v10", commit) + runGit("", "update-ref", "refs/heads/zzbranch", commit) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "url.file://"+repository+".insteadOf") + t.Setenv("GIT_CONFIG_VALUE_0", "https://example.test/owner/repo") + + source := Source{Type: "git", URL: "https://example.test/owner/repo/archive/v1.tar.gz"} + version, err := source.fetchLatestGitTag(context.Background()) + + if err != nil || version != "v10" { + t.Fatalf("latest tag = %q, error = %v", version, err) + } +} + func TestUpdatePolicies(t *testing.T) { cases := []struct { name string diff --git a/yae.go b/yae.go index 5510b57..3da6a77 100644 --- a/yae.go +++ b/yae.go @@ -1,8 +1,11 @@ package main import ( + "context" "fmt" "os" + "os/signal" + "syscall" "time" "github.com/Fuwn/yae/internal/commands" @@ -14,9 +17,26 @@ import ( var Version string func main() { + context, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + + defer stop() + + if err := newApp().RunContext(context, os.Args); err != nil { + log.Fatal(err.Error()) + } +} + +func newApp() *cli.App { sources := yae.Environment{} + loadSources := func(context *cli.Context) error { + if err := sources.Load(context.String("sources")); os.IsNotExist(err) { + return fmt.Errorf("sources file is missing; run yae init to create it") + } else { + return err + } + } - if err := (&cli.App{ + return &cli.App{ Name: "yae", Version: Version, Usage: "Nix Dependency Manager", @@ -28,21 +48,18 @@ func main() { Email: "contact@fuwn.me", }, }, - Before: func(c *cli.Context) error { - if args := c.Args(); args.Len() == 1 && args.Get(0) == "init" { - return nil - } + Before: func(context *cli.Context) error { + log.SetLevel(log.InfoLevel) - location := c.String("sources") + if context.Bool("debug") { + log.SetLevel(log.DebugLevel) + } - if _, err := os.Stat(location); os.IsNotExist(err) { - return fmt.Errorf( - "file `%s` was not present, run `yae init` to create it", - location, - ) + if context.Bool("silent") { + log.SetLevel(log.ErrorLevel) } - return sources.Load(location) + return nil }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -53,33 +70,18 @@ func main() { &cli.BoolFlag{ Name: "debug", Usage: "Enable debug output", - Action: func(*cli.Context, bool) error { - log.SetLevel(log.DebugLevel) - - return nil - }, }, &cli.BoolFlag{ Name: "silent", - Usage: "Silence log output", - Action: func(*cli.Context, bool) error { - log.SetLevel(log.WarnLevel) - - return nil - }, + Usage: "Only log errors", }, &cli.BoolFlag{ Name: "dry-run", - Usage: "Prevents writing to disk", + Usage: "Preview changes without saving the sources file (downloads may still populate the Nix store)", }, }, - Copyright: fmt.Sprintf("Copyright (c) 2024-%s Fuwn", fmt.Sprint(time.Now().Year())), - ExitErrHandler: func(c *cli.Context, err error) { - if err != nil { - log.Fatal(err.Error()) - } - }, - Suggest: true, + Copyright: fmt.Sprintf("Copyright (c) 2024-%d Fuwn", time.Now().Year()), + Suggest: true, Commands: []*cli.Command{ { Name: "init", @@ -92,6 +94,7 @@ func main() { ArgsUsage: " ", Usage: "Add a source", Flags: commands.AddFlags(), + Before: loadSources, Action: commands.Add(&sources), }, { @@ -99,6 +102,7 @@ func main() { ArgsUsage: "", Args: true, Usage: "Drop a source", + Before: loadSources, Action: commands.Drop(&sources), }, { @@ -107,10 +111,9 @@ func main() { Usage: "Update one or all sources", ArgsUsage: "[name]", Flags: commands.UpdateFlags(), + Before: loadSources, Action: commands.Update(&sources), }, }, - }).Run(os.Args); err != nil { - log.Fatal(err.Error()) } } diff --git a/yae_test.go b/yae_test.go new file mode 100644 index 0000000..edae3ed --- /dev/null +++ b/yae_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Fuwn/yae/internal/yae" + "github.com/charmbracelet/log" +) + +const fixtureSHA256 = "1wn29537l343lb0id0byk0699fj0k07m1n2d7jx2n0ssax55vhwy" +const fixtureSRIHash = "sha256-nsNdSldaAyu6PE3YUA+YQLqUDJh+gRbBooMMekZJwvI=" + +func runCLI(t *testing.T, path string, arguments ...string) (string, string, error) { + t.Helper() + + var output, diagnostics bytes.Buffer + + log.SetOutput(&diagnostics) + + defer log.SetOutput(os.Stderr) + + application := newApp() + + application.Writer = &output + application.ErrWriter = &diagnostics + + err := application.RunContext(context.Background(), append([]string{"yae", "--sources", path}, arguments...)) + + return output.String(), diagnostics.String(), err +} + +func installFixtureCommands(t *testing.T) { + t.Helper() + + shell, err := exec.LookPath("sh") + + if err != nil { + t.Fatal(err) + } + + directory := t.TempDir() + commands := map[string]string{ + "git": "printf 'abc\\trefs/tags/v2\\n'", + "nix": "printf '%s\\n' '" + fixtureSRIHash + "'", + "nix-prefetch-url": "case \"$*\" in *fail*) printf 'fetch failed' >&2; exit 23;; esac\nprintf '%s\\n' '" + fixtureSHA256 + "'", + } + + for name, body := range commands { + if err := os.WriteFile(filepath.Join(directory, name), []byte("#!"+shell+"\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + + t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func readSources(t *testing.T, path string) yae.Environment { + t.Helper() + + var environment yae.Environment + + if err := environment.Load(path); err != nil { + t.Fatal(err) + } + + return environment +} + +func TestHelpWithoutEnvironment(t *testing.T) { + for _, arguments := range [][]string{{}, {"--help"}, {"help", "add"}, {"init", "--help"}, {"add", "--help"}, {"update", "--help"}, {"drop", "--help"}} { + output, _, err := runCLI(t, filepath.Join(t.TempDir(), "missing.json"), arguments...) + + if err != nil || !strings.Contains(output, "USAGE:") { + t.Fatalf("help %v failed: %s, %v", arguments, output, err) + } + } +} + +func TestCommandLifecycle(t *testing.T) { + installFixtureCommands(t) + + path := filepath.Join(t.TempDir(), "sources.json") + + for _, arguments := range [][]string{ + {"init"}, + {"add", "--type", "binary", "binary", "https://example.test/file"}, + {"add", "--type", "git", "--version", "v1", "release", "https://example.test/owner/repo/archive/{version}"}, + } { + if _, _, err := runCLI(t, path, arguments...); err != nil { + t.Fatal(err) + } + } + + environment := readSources(t, path) + + for name, source := range environment.Sources { + if source.SHA256 != fixtureSHA256 || source.Hash != fixtureSRIHash { + t.Fatalf("%s was added with incomplete hashes: %#v", name, source) + } + } + + output, _, err := runCLI(t, path, "update", "--output-updated-list", "release") + + if err != nil || output != "release\n" { + t.Fatalf("update output = %q, error = %v", output, err) + } + + environment = readSources(t, path) + + if environment.Sources["release"].Version != "v2" || !strings.HasSuffix(environment.Sources["release"].URL, "/v2") { + t.Fatalf("release did not advance: %#v", environment.Sources["release"]) + } + + if _, _, err := runCLI(t, path, "drop", "binary"); err != nil { + t.Fatal(err) + } + + if readSources(t, path).Exists("binary") { + t.Fatal("dropped source remains") + } +} + +func TestInvalidArgumentsPreserveFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "sources.json") + + if _, _, err := runCLI(t, path, "init"); err != nil { + t.Fatal(err) + } + + original, err := os.ReadFile(path) + + if err != nil { + t.Fatal(err) + } + + for _, arguments := range [][]string{ + {"init"}, {"init", "extra"}, {"drop"}, {"drop", "one", "two"}, + {"update", "one", "two"}, {"update", "absent"}, {"drop", "absent"}, + {"add", "--type", "binary", "$schema", "https://example.test/file"}, + {"add", "--type", "git", "missing-version", "https://example.test/owner/repo/archive/v1"}, + } { + if _, _, err := runCLI(t, path, arguments...); err == nil { + t.Fatalf("accepted invalid arguments: %v", arguments) + } + + data, err := os.ReadFile(path) + + if err != nil || !bytes.Equal(data, original) { + t.Fatalf("invalid command %v changed file: %v", arguments, err) + } + } +} + +func TestDryRunPreservesFiles(t *testing.T) { + installFixtureCommands(t) + + path := filepath.Join(t.TempDir(), "sources.json") + + if _, _, err := runCLI(t, path, "--dry-run", "init"); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("dry-run init created a file") + } + + if _, _, err := runCLI(t, path, "init"); err != nil { + t.Fatal(err) + } + + if _, _, err := runCLI(t, path, "add", "--type", "git", "--version", "v1", "release", "https://example.test/owner/repo/archive/{version}"); err != nil { + t.Fatal(err) + } + + original, err := os.ReadFile(path) + + if err != nil { + t.Fatal(err) + } + + for _, arguments := range [][]string{{"drop", "release"}, {"update"}, {"add", "--type", "binary", "new", "https://example.test/file"}} { + if _, _, err := runCLI(t, path, append([]string{"--dry-run"}, arguments...)...); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + + if err != nil || !bytes.Equal(data, original) { + t.Fatalf("dry-run %v changed file: %v", arguments, err) + } + } +} + +func TestBooleanLoggingFlags(t *testing.T) { + installFixtureCommands(t) + + path := filepath.Join(t.TempDir(), "sources.json") + + if _, _, err := runCLI(t, path, "init"); err != nil { + t.Fatal(err) + } + + if _, _, err := runCLI(t, path, "add", "--type", "binary", "sample", "https://example.test/file"); err != nil { + t.Fatal(err) + } + + cases := []struct { + flag string + info bool + debug bool + }{ + {flag: "--debug=false", info: true}, + {flag: "--silent=false", info: true}, + {flag: "--debug", info: true, debug: true}, + {flag: "--silent"}, + } + + for _, test := range cases { + _, diagnostics, err := runCLI(t, path, test.flag, "update") + + if err != nil || strings.Contains(diagnostics, "checking sample") != test.info || strings.Contains(diagnostics, "running nix") != test.debug { + t.Fatalf("%s: diagnostics = %q, error = %v", test.flag, diagnostics, err) + } + } +} + +func TestUpdateIsOrderedAndFailureDoesNotSave(t *testing.T) { + installFixtureCommands(t) + + for _, failing := range []bool{false, true} { + path := filepath.Join(t.TempDir(), "sources.json") + source := yae.Source{URL: "https://example.test/file", Type: "binary", SHA256: strings.Repeat("0", 52), Hash: "sha256-" + strings.Repeat("A", 43) + "="} + environment := yae.Environment{Sources: map[string]yae.Source{"zulu": source, "alpha": source}} + + if failing { + source.URL = "https://example.test/fail" + environment.Sources["zulu"] = source + } + + if err := environment.Save(path); err != nil { + t.Fatal(err) + } + + original, err := os.ReadFile(path) + + if err != nil { + t.Fatal(err) + } + + output, diagnostics, err := runCLI(t, path, "update", "--output-updated-list") + + if failing { + data, readError := os.ReadFile(path) + + if err == nil || output != "" || strings.Contains(diagnostics, "updated ") || readError != nil || !bytes.Equal(original, data) { + t.Fatalf("failed update published changes: %q, %q, %v, %v", output, diagnostics, err, readError) + } + } else if err != nil || output != "alpha\nzulu\n" { + t.Fatalf("unordered update: %q, %v", output, err) + } + } +} + +func TestPinnedLegacyHashRepair(t *testing.T) { + installFixtureCommands(t) + + path := filepath.Join(t.TempDir(), "sources.json") + source := yae.Source{URL: "https://example.test/file", Type: "binary", SHA256: fixtureSHA256, Pinned: true} + data, err := json.Marshal(map[string]yae.Source{"pinned": source}) + + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + if _, _, err := runCLI(t, path, "update"); err != nil { + t.Fatal(err) + } + + updated := readSources(t, path).Sources["pinned"] + + if updated.Hash != fixtureSRIHash || updated.SHA256 != source.SHA256 || !updated.Pinned || updated.URL != source.URL { + t.Fatalf("repair changed pinned source: %#v", updated) + } +} From 5adceeebbaef53e2b50f15cc0bf99c0246416f8b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 06:37:00 +0000 Subject: [PATCH 6/8] fix: keep newly created environment files private --- internal/yae/environment.go | 2 +- internal/yae/environment_test.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/yae/environment.go b/internal/yae/environment.go index 3c2e072..c280629 100644 --- a/internal/yae/environment.go +++ b/internal/yae/environment.go @@ -78,7 +78,7 @@ func (environment *Environment) Save(path string) error { return err } - mode := os.FileMode(0o644) + mode := os.FileMode(0o600) if information, err := os.Stat(path); err == nil { if !information.Mode().IsRegular() { diff --git a/internal/yae/environment_test.go b/internal/yae/environment_test.go index debd070..3a9dd0f 100644 --- a/internal/yae/environment_test.go +++ b/internal/yae/environment_test.go @@ -109,3 +109,18 @@ func TestFailedLoadPreservesEnvironment(t *testing.T) { } } } + +func TestNewEnvironmentIsPrivate(t *testing.T) { + path := filepath.Join(t.TempDir(), "sources.json") + environment := Environment{} + + if err := environment.Save(path); err != nil { + t.Fatal(err) + } + + information, err := os.Stat(path) + + if err != nil || information.Mode().Perm() != 0o600 { + t.Fatalf("new environment permissions are not private: %v", err) + } +} From d3ce32bbcc166662aee840ad946d9373952c7946 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 07:12:15 +0000 Subject: [PATCH 7/8] chore: simplify Nix packaging and declare runtime tools --- .gitignore | 2 +- flake.lock | 92 +++--------------------------------------------- flake.nix | 101 ++++++++++++++++++----------------------------------- go.mod | 6 ++-- go.sum | 8 +++++ 5 files changed, 52 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index 1458f96..f7512b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /yae -.pre-commit-config.yaml result /yae.json +.DS_Store diff --git a/flake.lock b/flake.lock index 761700c..d11310d 100644 --- a/flake.lock +++ b/flake.lock @@ -1,21 +1,5 @@ { "nodes": { - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, "flake-utils": { "inputs": { "systems": [ @@ -23,11 +7,10 @@ ] }, "locked": { - "lastModified": 1726560853, - "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", + "lastModified": 1731533236, "owner": "numtide", "repo": "flake-utils", - "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -36,96 +19,31 @@ "type": "github" } }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "pre-commit-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1730551372, - "narHash": "sha256-kD10NQ+5MScsRXZcppfNuJNqfh481YQhL/mBXQ5OCLs=", + "lastModified": 1789485310, "owner": "NixOS", "repo": "nixpkgs", - "rev": "bfdd1148ed703ef0134aec793ad1e0fcd9a2ab59", + "rev": "0c32f40fe3e2a9adfc427fd5abc061a31043ea44", "type": "github" }, "original": { "owner": "NixOS", + "ref": "nixpkgs-26.05-darwin", "repo": "nixpkgs", "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1720386169, - "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "194846768975b7ad2c4988bdb82572c00222c0d7", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "pre-commit-hooks": { - "inputs": { - "flake-compat": [ - "flake-compat" - ], - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1726745158, - "narHash": "sha256-D5AegvGoEjt4rkKedmxlSEmC+nNLMBPWFxvmYnVLhjk=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "4e743a6920eab45e8ba0fbe49dc459f1423a4b74", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "root": { "inputs": { - "flake-compat": "flake-compat", "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "pre-commit-hooks": "pre-commit-hooks", "systems": "systems" } }, "systems": { "locked": { "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", diff --git a/flake.nix b/flake.nix index f3ec7c1..5d01a26 100644 --- a/flake.nix +++ b/flake.nix @@ -2,27 +2,13 @@ description = "Nix Dependency Manager"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin"; systems.url = "github:nix-systems/default"; - flake-compat = { - url = "github:edolstra/flake-compat"; - flake = false; - }; - flake-utils = { url = "github:numtide/flake-utils"; inputs.systems.follows = "systems"; }; - - pre-commit-hooks = { - url = "github:cachix/git-hooks.nix"; - - inputs = { - flake-compat.follows = "flake-compat"; - nixpkgs.follows = "nixpkgs"; - }; - }; }; outputs = @@ -30,14 +16,11 @@ self, nixpkgs, flake-utils, - pre-commit-hooks, ... }: flake-utils.lib.eachDefaultSystem ( system: let - inherit (pkgs.stdenv) isDarwin; - pkgs = import nixpkgs { inherit system; }; name = "yae"; @@ -49,42 +32,37 @@ licenses.asl20 ]; maintainers = [ maintainers.Fuwn ]; - mainPackage = name; + mainProgram = name; platforms = platforms.unix; }; - yae = - pkgs.buildGo123Module.override - { - stdenv = if isDarwin then pkgs.clangStdenv else pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenv; - } - rec { - inherit meta; - - pname = name; - version = "2025.11.29"; - src = pkgs.lib.cleanSource ./.; - vendorHash = "sha256-XQEB2vgiztbtLnc7BR4WTouPI+2NDQXXFUNidqmvbac="; - buildInputs = if isDarwin then [ ] else [ pkgs.musl ]; - propagatedBuildInputs = [ pkgs.gitMinimal ]; + yae = pkgs.buildGoModule rec { + inherit meta; + + pname = name; + version = "2025.11.29"; + src = pkgs.lib.cleanSource ./.; + vendorHash = "sha256-XQEB2vgiztbtLnc7BR4WTouPI+2NDQXXFUNidqmvbac="; + env.CGO_ENABLED = 0; + nativeBuildInputs = [ pkgs.makeWrapper ]; + nativeCheckInputs = [ + pkgs.gitMinimal + ]; + ldflags = [ + "-s" + "-w" + "-X main.Version=${version}" + ]; - ldflags = - [ - "-s" - "-w" - "-X main.Version=${version}" - "-X main.Commit=${version}" - ] - ++ ( - if isDarwin then - [ ] - else - [ - "-linkmode=external" - "-extldflags=-static" - ] - ); - }; + postInstall = '' + wrapProgram "$out/bin/yae" --prefix PATH : ${ + pkgs.lib.makeBinPath [ + pkgs.gitMinimal + pkgs.nix + ] + } + ''; + }; in { packages = { @@ -103,24 +81,13 @@ ${name} = self.apps.${system}.default; }; - formatter = nixpkgs.legacyPackages."${system}".nixfmt-rfc-style; - - checks.pre-commit-check = pre-commit-hooks.lib.${system}.run { - src = ./.; - - hooks = { - deadnix.enable = true; - flake-checker.enable = true; - nixfmt-rfc-style.enable = true; - statix.enable = true; - }; - }; - - devShells.default = nixpkgs.legacyPackages.${system}.mkShell { - inherit (self.checks.${system}.pre-commit-check) shellHook; + formatter = nixpkgs.legacyPackages."${system}".nixfmt; - buildInputs = self.checks.${system}.pre-commit-check.enabledPackages ++ [ - pkgs.go_1_23 + devShells.default = pkgs.mkShell { + packages = [ + pkgs.go + pkgs.gitMinimal + pkgs.nix ]; }; } diff --git a/go.mod b/go.mod index e39c76a..68630dd 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,14 @@ module github.com/Fuwn/yae go 1.22.7 -require github.com/urfave/cli/v2 v2.27.4 +require ( + github.com/charmbracelet/log v0.4.0 + github.com/urfave/cli/v2 v2.27.4 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/lipgloss v0.10.0 // indirect - github.com/charmbracelet/log v0.4.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/go.sum b/go.sum index 016ac31..17613bb 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8 github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -19,12 +21,16 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -34,3 +40,5 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQz golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 7511646acfecd43bc95e102bf5dfe9f09548c1c9 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 22 Sep 2026 07:13:10 +0000 Subject: [PATCH 8/8] test: check packaged CLI and repository contracts --- .github/workflows/check.yml | 25 ++++++ examples/nixpkgs-simple/flake.nix | 4 +- examples/nixpkgs/flake.nix | 4 +- flake.nix | 52 ++++++++++++- go.mod | 1 + go.sum | 2 + internal/yae/command_test.go | 24 ------ internal/yae/environment_test.go | 15 ---- internal/yae/source_test.go | 4 + internal/yae/validation_test.go | 55 ++------------ runtime_test.go | 122 ++++++++++++++++++++++++++++++ schema_test.go | 77 +++++++++++++++++++ 12 files changed, 294 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 runtime_test.go create mode 100644 schema_test.go diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..90e1708 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,25 @@ +name: Check + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + check: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 + - name: Check package, tests, schema, formatting, and runtime + run: nix flake check --no-write-lock-file --print-build-logs diff --git a/examples/nixpkgs-simple/flake.nix b/examples/nixpkgs-simple/flake.nix index 2903230..9ded79c 100644 --- a/examples/nixpkgs-simple/flake.nix +++ b/examples/nixpkgs-simple/flake.nix @@ -2,7 +2,7 @@ outputs = { self }: let - nixpkgs = (builtins.fromJSON (builtins.readFile "${self}/yae.json")).nixpkgs; + inherit (builtins.fromJSON (builtins.readFile "${self}/yae.json")) nixpkgs; pkgs = import @@ -13,6 +13,6 @@ }; in { - packages.${pkgs.system}.hello = pkgs.hello; + packages.${pkgs.system} = { inherit (pkgs) hello; }; }; } diff --git a/examples/nixpkgs/flake.nix b/examples/nixpkgs/flake.nix index 7dd28a4..fdefe52 100644 --- a/examples/nixpkgs/flake.nix +++ b/examples/nixpkgs/flake.nix @@ -2,7 +2,7 @@ outputs = { self }: let - nixpkgs = (builtins.fromJSON (builtins.readFile "${self}/yae.json")).nixpkgs; + inherit (builtins.fromJSON (builtins.readFile "${self}/yae.json")) nixpkgs; systemsFlakeExposed = [ "x86_64-linux" @@ -40,7 +40,7 @@ }; in { - hello = pkgs.hello; + inherit (pkgs) hello; } ); }; diff --git a/flake.nix b/flake.nix index 5d01a26..9970396 100644 --- a/flake.nix +++ b/flake.nix @@ -42,7 +42,7 @@ pname = name; version = "2025.11.29"; src = pkgs.lib.cleanSource ./.; - vendorHash = "sha256-XQEB2vgiztbtLnc7BR4WTouPI+2NDQXXFUNidqmvbac="; + vendorHash = "sha256-TSpb8oiLdVlBpUCAlRXx+LQt8ZcZZslId02hP3qRRlA="; env.CGO_ENABLED = 0; nativeBuildInputs = [ pkgs.makeWrapper ]; nativeCheckInputs = [ @@ -54,6 +54,18 @@ "-X main.Version=${version}" ]; + preBuild = '' + export HOME="$TMPDIR" + ''; + + checkPhase = '' + runHook preCheck + go test ./... + go vet ./... + test -z "$(gofmt -l *.go internal)" + runHook postCheck + ''; + postInstall = '' wrapProgram "$out/bin/yae" --prefix PATH : ${ pkgs.lib.makeBinPath [ @@ -83,11 +95,49 @@ formatter = nixpkgs.legacyPackages."${system}".nixfmt; + checks = { + package = yae; + runtime = pkgs.runCommand "yae-runtime-check" { nativeBuildInputs = [ pkgs.go ]; } '' + export HOME="$TMPDIR" GOCACHE="$TMPDIR/go-cache" CGO_ENABLED=0 + export YAE_TEST_BINARY=${yae}/bin/yae YAE_TEST_GIT=${pkgs.gitMinimal}/bin/git + go test -v ${./runtime_test.go} -run '^TestPackagedRuntime$' + touch "$out" + ''; + + nix = + pkgs.runCommand "yae-nix-check" + { + nativeBuildInputs = [ + pkgs.deadnix + pkgs.flake-checker + pkgs.nixfmt + pkgs.statix + pkgs.actionlint + ]; + } + '' + export HOME="$TMPDIR" + + cd ${self} + deadnix --fail . + flake-checker -f + nixfmt --check flake.nix examples/*/flake.nix + statix check . + actionlint .github/workflows/check.yml + touch "$out" + ''; + }; + devShells.default = pkgs.mkShell { packages = [ pkgs.go pkgs.gitMinimal pkgs.nix + pkgs.deadnix + pkgs.flake-checker + pkgs.nixfmt + pkgs.statix + pkgs.actionlint ]; }; } diff --git a/go.mod b/go.mod index 68630dd..5699ee1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.7 require ( github.com/charmbracelet/log v0.4.0 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/urfave/cli/v2 v2.27.4 ) diff --git a/go.sum b/go.sum index 17613bb..ab03bf3 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= diff --git a/internal/yae/command_test.go b/internal/yae/command_test.go index ff454ef..728a582 100644 --- a/internal/yae/command_test.go +++ b/internal/yae/command_test.go @@ -81,27 +81,3 @@ func TestFailedHashConversionLeavesSourceUnchanged(t *testing.T) { t.Fatalf("failed conversion changed source: %#v, %v", source, err) } } - -func TestFailedUpdateLeavesSourceUnchanged(t *testing.T) { - fakeGit(t, "abc\trefs/tags/v2\n", false) - fakeCommand(t, "nix-prefetch-url", "exit 1") - - source := Source{Type: "git", Version: "v1", URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", SHA256: testSHA256, Hash: testSRIHash} - original := source - - if _, err := source.Update(context.Background(), false, false); err == nil || source != original { - t.Fatalf("failed update changed source: %#v, %v", source, err) - } -} - -func TestForcedRehashDoesNotInventAChange(t *testing.T) { - fakeGit(t, "abc\trefs/tags/v1\n", false) - fakeNix(t) - - source := Source{Type: "git", Version: "v1", URL: "https://example.test/owner/repo/archive/v1", URLTemplate: "https://example.test/owner/repo/archive/{version}", SHA256: testSHA256, Hash: testSRIHash} - updated, err := source.Update(context.Background(), true, false) - - if err != nil || updated != source { - t.Fatalf("unchanged content reported as changed: %#v, %v", updated, err) - } -} diff --git a/internal/yae/environment_test.go b/internal/yae/environment_test.go index 3a9dd0f..da9495a 100644 --- a/internal/yae/environment_test.go +++ b/internal/yae/environment_test.go @@ -95,21 +95,6 @@ func TestFailedSavePreservesDestination(t *testing.T) { } } -func TestFailedLoadPreservesEnvironment(t *testing.T) { - for _, contents := range []string{`{"$schema": false}`, `{} {}`, `{"source": false}`} { - path := filepath.Join(t.TempDir(), "sources.json") - environment := Environment{Schema: "unchanged"} - - if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { - t.Fatal(err) - } - - if err := environment.Load(path); err == nil || environment.Schema != "unchanged" { - t.Fatalf("invalid input changed environment: %#v, %v", environment, err) - } - } -} - func TestNewEnvironmentIsPrivate(t *testing.T) { path := filepath.Join(t.TempDir(), "sources.json") environment := Environment{} diff --git a/internal/yae/source_test.go b/internal/yae/source_test.go index 92deef2..071c982 100644 --- a/internal/yae/source_test.go +++ b/internal/yae/source_test.go @@ -209,6 +209,10 @@ func TestUpdatePolicies(t *testing.T) { t.Fatalf("updated = %#v, error = %v", updated, err) } + if test.wantVersion == source.Version && updated != source { + t.Fatalf("unchanged content reported as changed: %#v", updated) + } + _, err = os.Stat(marker) if (err == nil) != test.fetch { diff --git a/internal/yae/validation_test.go b/internal/yae/validation_test.go index 4d99eb2..b5364cc 100644 --- a/internal/yae/validation_test.go +++ b/internal/yae/validation_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "testing" ) @@ -30,6 +29,10 @@ func TestInvalidStoredSources(t *testing.T) { "null schema": `{"$schema":null}`, "reserved name": `{"$schema":{}}`, "null source": `{"sample":null}`, + "invalid schema": `{"$schema":false}`, + "trailing object": `{} {}`, + "invalid source": `{"sample":false}`, + "missing unpack": `{"sample":{"url":"https://example.test/file","type":"binary","sha256":"` + testSHA256 + `"}}`, } changes := map[string]map[string]any{ "unknown field": {"future_option": true}, @@ -46,23 +49,16 @@ func TestInvalidStoredSources(t *testing.T) { } for name, fields := range changes { - data, err := json.Marshal(validSource()) - - if err != nil { - t.Fatal(err) - } - - var source map[string]any - - if err := json.Unmarshal(data, &source); err != nil { - t.Fatal(err) + source := map[string]any{ + "url": "https://example.test/file", "type": "binary", "unpack": true, + "sha256": testSHA256, "hash": testSRIHash, } for field, value := range fields { source[field] = value } - data, err = json.Marshal(map[string]any{"sample": source}) + data, err := json.Marshal(map[string]any{"sample": source}) if err != nil { t.Fatal(err) @@ -122,20 +118,6 @@ func TestValidationFailurePreservesFile(t *testing.T) { } } -func TestRefreshPopulatesBothHashes(t *testing.T) { - fakeNix(t) - - source := Source{URL: "https://example.test/file", Type: "binary"} - - if err := source.RefreshHashes(context.Background()); err != nil { - t.Fatal(err) - } - - if source.SHA256 != testSHA256 || source.Hash != testSRIHash { - t.Fatalf("incomplete hashes: %#v", source) - } -} - func TestUpdateRepairsLegacyHash(t *testing.T) { cases := []struct { name string @@ -160,27 +142,6 @@ func TestUpdateRepairsLegacyHash(t *testing.T) { } } -func TestMissingRequiredField(t *testing.T) { - data, err := json.Marshal(validSource()) - - if err != nil { - t.Fatal(err) - } - - path := filepath.Join(t.TempDir(), "sources.json") - contents := `{"sample":` + strings.Replace(string(data), `"unpack":true,`, "", 1) + `}` - - if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { - t.Fatal(err) - } - - var environment Environment - - if err := environment.Load(path); err == nil { - t.Fatal("missing unpack accepted") - } -} - func TestLegacyBinaryOptionsRoundTrip(t *testing.T) { for _, option := range []string{"force", "trim_tag_prefix", "tag_predicate"} { t.Run(option, func(t *testing.T) { diff --git a/runtime_test.go b/runtime_test.go new file mode 100644 index 0000000..3089bd2 --- /dev/null +++ b/runtime_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPackagedRuntime(t *testing.T) { + binary := os.Getenv("YAE_TEST_BINARY") + git := os.Getenv("YAE_TEST_GIT") + + if binary == "" || git == "" { + t.Skip("set YAE_TEST_BINARY and YAE_TEST_GIT to test the installed package") + } + + root := t.TempDir() + sources := filepath.Join(root, "sources.json") + payload := filepath.Join(root, "payload") + repository := filepath.Join(root, "repository") + + for name, value := range map[string]string{ + "PATH": "", "HOME": root, + "NIX_REMOTE": "local?root=" + filepath.Join(root, "nix"), + "NIX_CONF_DIR": filepath.Join(root, "config"), "NIX_USER_CONF_FILES": "", + "NIX_CONFIG": "experimental-features = nix-command\nbuild-users-group =\n", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.DevNull, + } { + t.Setenv(name, value) + } + + run := func(executable string, arguments ...string) string { + t.Helper() + + context, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + defer cancel() + + process := exec.CommandContext(context, executable, arguments...) + + var diagnostics bytes.Buffer + + process.Stderr = &diagnostics + + output, err := process.Output() + + if err != nil { + t.Fatalf("%s %v: %v: %s", executable, arguments, err, diagnostics.String()) + } + + return strings.TrimSpace(string(output)) + } + runYae := func(arguments ...string) string { + return run(binary, append([]string{"--sources", sources}, arguments...)...) + } + write := func(path string, contents []byte) { + t.Helper() + + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + } + + runYae("init") + write(payload, []byte("first payload")) + runYae("add", "--type", "binary", "--unpack=false", "sample", (&url.URL{Scheme: "file", Path: payload}).String()) + + contents, err := os.ReadFile(sources) + + if err != nil { + t.Fatal(err) + } + + var environment map[string]any + + if err := json.Unmarshal(contents, &environment); err != nil { + t.Fatal(err) + } + + source := environment["sample"].(map[string]any) + digest := sha256.Sum256([]byte("first payload")) + expected := "sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + + if source["hash"] != expected { + t.Fatalf("saved hash = %v; want %s", source["hash"], expected) + } + + run(git, "init", "--bare", repository) + + tree := run(git, "-C", repository, "mktree") + commit := run(git, "-C", repository, "-c", "user.name=Yae Test", "-c", "user.email=yae@example.test", "commit-tree", tree, "-m", "fixture") + + run(git, "-C", repository, "update-ref", "refs/tags/v10", commit) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "url."+(&url.URL{Scheme: "file", Path: repository}).String()+".insteadOf") + t.Setenv("GIT_CONFIG_VALUE_0", "https://example.test/owner/repository") + + source["type"] = "git" + source["version"] = "v10" + source["url"] = "https://example.test/owner/repository/archive/v10" + source["url_template"] = "https://example.test/owner/repository/archive/{version}" + contents, err = json.Marshal(environment) + + if err != nil { + t.Fatal(err) + } + + write(sources, contents) + + if output := runYae("update", "--output-updated-list"); output != "" { + t.Fatalf("packaged Git check changed a current source: %s", output) + } +} diff --git a/schema_test.go b/schema_test.go new file mode 100644 index 0000000..80fb94d --- /dev/null +++ b/schema_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" +) + +func TestSchema(t *testing.T) { + schema, err := jsonschema.Compile("yae.schema.json") + + if err != nil { + t.Fatal(err) + } + + read := func(path string) map[string]any { + t.Helper() + + contents, err := os.ReadFile(path) + + if err != nil { + t.Fatal(err) + } + + var environment map[string]any + + if err := json.Unmarshal(contents, &environment); err != nil { + t.Fatal(err) + } + + return environment + } + paths, err := filepath.Glob("examples/*/yae.json") + + if err != nil || len(paths) == 0 { + t.Fatalf("example discovery: %v", err) + } + + for _, path := range paths { + if err := schema.Validate(read(path)); err != nil { + t.Fatalf("%s: %v", path, err) + } + } + + for _, invalid := range []any{nil, map[string]any{"$schema": nil}, map[string]any{"source": nil}, map[string]any{"source": map[string]any{}}} { + if schema.Validate(invalid) == nil { + t.Fatalf("schema accepted invalid environment: %#v", invalid) + } + } + + for field, value := range map[string]any{ + "type": "unsupported", "sha256": "invalid", "hash": "sha256-invalid", + "unpack": nil, "future_option": true, + } { + invalid := read("examples/nixpkgs/yae.json") + + invalid["nixpkgs"].(map[string]any)[field] = value + + if schema.Validate(invalid) == nil { + t.Fatalf("schema accepted invalid %s", field) + } + } + + legacy := read("examples/nixpkgs/yae.json") + source := legacy["nixpkgs"].(map[string]any) + + source["force"] = true + source["trim_tag_prefix"] = "v" + source["tag_predicate"] = "^v" + + if err := schema.Validate(legacy); err != nil { + t.Fatal(err) + } +}