Skip to content

Commit ae68923

Browse files
authored
Module manager: overridable manifest source + stronger install verification (#618)
# Description Adds the ability to temporarily point the module manager at an alternate manifest (registry) — via a one-shot CLI switch or an interactive command — primarily for local testing of modules and registries. Also hardens module installation so a download is always verified against the checksum declared in the manifest, and makes a non-default manifest impossible to miss while working interactively. ## Changes ### 1. Temporary manifest override - **Global `--manifest <path|url>` switch** for one-shot commands (`internal/modmanager/modmanager.go`): - Accepts an `http(s)` URL **or** a local filesystem path (a `file://` prefix is also accepted), enabling fully local testing. - May appear anywhere in the command; it is stripped from args before subcommand dispatch (`applyManifestFlag`). - Supports both `--manifest x.yaml` and `--manifest=x.yaml` forms. - **Validates** that the source ends in `.yaml`/`.yml` (URL query/fragment ignored); otherwise errors out (`validateManifestSource`). - **Warns** whenever a non-default manifest is in use. - **Manifest loading now supports local files** (`internal/modmanager/registry.go`): - `fetchRegistry` reads from a local path / `file://` or fetches over `http(s)` based on the configured source. - New `manifestSource` package variable holds the active source (defaults to the official registry URL). ### 2. Stronger install verification `internal/modmanager/install.go`: - **Refuses to install** a manifest entry that has no `sha256` checksum ("refusing to install unverifiable module") instead of failing with a confusing mismatch. - The downloaded archive is streamed through `verifyArchive`, which fails with a clear `SHA256 mismatch` error when the bytes don't match the manifest. On failure no module directory is created. - Adds a visible `Verified SHA256 checksum.` step on success. - Module archives referenced by a manifest `url` may now be **local paths or `file://` URLs** (`openArchiveReader`), so a module can be installed entirely from local files for testing — still SHA256-verified. ### 3. Interactive `manifest-source` command `internal/modmanager/interactive.go`: - New REPL command `manifest-source` (alias `manifest`) — the interactive context can't use the `--manifest` switch, so this changes the source for the rest of the session: - no argument → prints the current source, - `<path|url>` → validates (`.yaml`), sets it, and warns, - `default` / `reset` → restores the default registry. - Added to the interactive help listing. ### 4. Persistent visual warning for a non-default manifest - When the active manifest differs from the default registry, a **color-emphasized banner** (bold black on a yellow background) is printed **above the prompt on every interactive iteration**, so the non-default state stays visible the whole session (`printCustomManifestBanner`, `warnBanner` in `internal/modmanager/color.go`). The banner disappears once the source is reset to default. ### 5. Tests & docs - `internal/modmanager/modmanager_test.go`: added coverage for `validateManifestSource`, `applyManifestFlag` (and error paths), `handleManifestSourceCommand` (show/set/reset/invalid), local-file `fetchRegistry`, `isHTTPURL`, and `openArchiveReader` (local file + missing). - `modules/README.md`: documented the `--manifest` switch, local-path/`file://` support, the interactive `manifest-source` command, and that downloads are SHA256-verified in all cases. ## Notes - The override is **temporary** by design: the `--manifest` switch affects a single command, and the interactive `manifest-source` command affects only the current session. Nothing is persisted to disk.
1 parent 2297bbf commit ae68923

7 files changed

Lines changed: 411 additions & 13 deletions

File tree

internal/modmanager/color.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ func header(title string) string {
6868
return cyan(title)
6969
}
7070

71+
// warnBanner renders an attention-grabbing warning (bold black text on a yellow
72+
// background) so a non-default state stays visible.
73+
func warnBanner(s string) string {
74+
return col("\033[1;30;43m", " "+s+" ")
75+
}
76+
7177
// codeSnippet renders a shell command in a distinct style.
7278
func codeSnippet(s string) string {
7379
return col(ansiGray, " "+s)

internal/modmanager/install.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"compress/gzip"
88
"fmt"
99
"io"
10-
"net/http"
1110
"os"
1211
"path/filepath"
1312
"strings"
@@ -51,6 +50,13 @@ func cmdInstall(name string) error {
5150
}
5251
}
5352

53+
// A manifest entry must declare a checksum so the downloaded archive can be
54+
// verified against it. Refuse to install otherwise rather than fetching
55+
// unverifiable code.
56+
if strings.TrimSpace(entry.SHA256) == "" {
57+
return fmt.Errorf("manifest entry for %q has no sha256 checksum; refusing to install unverifiable module", name)
58+
}
59+
5460
printStep("Downloading %s %s...", bold(entry.Name), cyan("v"+entry.Version))
5561

5662
tmpFile, err := os.CreateTemp("", "gomud-module-*.tmp")
@@ -63,24 +69,24 @@ func cmdInstall(name string) error {
6369
os.Remove(tmpPath)
6470
}()
6571

66-
resp, err := http.Get(entry.URL)
72+
rc, err := openArchiveReader(entry.URL)
6773
if err != nil {
68-
return fmt.Errorf("downloading archive: %w", err)
69-
}
70-
defer resp.Body.Close()
71-
72-
if resp.StatusCode != http.StatusOK {
73-
return fmt.Errorf("downloading archive: HTTP %d from %s", resp.StatusCode, entry.URL)
74+
return err
7475
}
76+
defer rc.Close()
7577

76-
if err := verifyArchive(resp.Body, tmpFile, entry.SHA256); err != nil {
78+
// verifyArchive streams the download to disk while computing its SHA256 and
79+
// fails if it does not match the checksum declared in the manifest.
80+
if err := verifyArchive(rc, tmpFile, entry.SHA256); err != nil {
7781
return err
7882
}
7983

8084
if err := tmpFile.Close(); err != nil {
8185
return fmt.Errorf("flushing temp file: %w", err)
8286
}
8387

88+
printStep("Verified SHA256 checksum.")
89+
8490
destDir := filepath.Join("modules", name)
8591
if _, err := os.Stat(destDir); err == nil {
8692
fmt.Printf("%s Removing existing %s...\n", yellow("-"), gray(destDir))

internal/modmanager/interactive.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func runInteractive() {
1919

2020
scanner := bufio.NewScanner(os.Stdin)
2121
for {
22+
printCustomManifestBanner()
2223
fmt.Print(cyan(">") + " ")
2324
if !scanner.Scan() {
2425
// EOF (Ctrl-D)
@@ -92,6 +93,9 @@ func runInteractive() {
9293
printError("%v", err)
9394
}
9495

96+
case "manifest-source", "manifest":
97+
handleManifestSourceCommand(args)
98+
9599
default:
96100
printError("unknown command: %q (type 'help' for a list)", cmd)
97101
}
@@ -103,6 +107,48 @@ func isInteractiveTerminal() bool {
103107
return term.IsTerminal(int(os.Stdin.Fd()))
104108
}
105109

110+
// handleManifestSourceCommand implements the interactive "manifest-source"
111+
// command. With no argument it prints the current manifest source. With
112+
// "default" (or "reset") it restores the default registry. Otherwise it points
113+
// the manager at the given .yaml file or URL for the rest of the session.
114+
func handleManifestSourceCommand(args []string) {
115+
if len(args) == 0 {
116+
printCurrentManifestSource()
117+
return
118+
}
119+
120+
if args[0] == "default" || args[0] == "reset" {
121+
manifestSource = registryURL
122+
printSuccess("Manifest source reset to the default registry.")
123+
return
124+
}
125+
126+
if err := useManifestOverride(args[0]); err != nil {
127+
printError("%v", err)
128+
}
129+
}
130+
131+
// printCustomManifestBanner prints a prominent, color-emphasized warning above
132+
// the prompt whenever a non-default manifest source is active, so it stays
133+
// visible on every menu of the interactive session.
134+
func printCustomManifestBanner() {
135+
if manifestSource == registryURL {
136+
return
137+
}
138+
fmt.Println(warnBanner("⚠ CUSTOM MANIFEST SOURCE — not the default registry") +
139+
" " + yellow(manifestSource))
140+
}
141+
142+
// printCurrentManifestSource reports where the manifest is currently loaded from.
143+
func printCurrentManifestSource() {
144+
if manifestSource == registryURL {
145+
fmt.Println(gray("Manifest source: ") + dimStr("default registry"))
146+
fmt.Println(" " + gray(registryURL))
147+
return
148+
}
149+
fmt.Println(gray("Manifest source: ") + bold(manifestSource))
150+
}
151+
106152
func printInteractiveHelp() {
107153
fmt.Println()
108154
fmt.Println(bold("Commands:"))
@@ -115,6 +161,7 @@ func printInteractiveHelp() {
115161
{green("remove") + " <name>", "Remove an installed module"},
116162
{green("update") + " [name]", "Check for updates; update a specific module if name given"},
117163
{green("package") + " <name>", "Package a local module into a .tar.gz and print its SHA256"},
164+
{green("manifest-source") + " [src]", "Show, or set for this session, the manifest source (.yaml file or URL; 'default' to reset)"},
118165
{green("help"), "Show this help"},
119166
{green("quit") + " / " + green("exit"), "Exit the module manager"},
120167
}

internal/modmanager/modmanager.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ import (
66
"fmt"
77
"os"
88
"regexp"
9+
"strings"
910
)
1011

1112
var validName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
1213

1314
// Run is the entry point for the module manager subcommand. args should be
1415
// os.Args[2:] (everything after "module").
1516
func Run(args []string) {
17+
args, err := applyManifestFlag(args)
18+
if err != nil {
19+
fatalf("%v\n", err)
20+
}
21+
1622
if len(args) == 0 {
1723
if isInteractiveTerminal() {
1824
runInteractive()
@@ -22,7 +28,6 @@ func Run(args []string) {
2228
os.Exit(0)
2329
}
2430

25-
var err error
2631
switch args[0] {
2732
case "list":
2833
err = cmdList()
@@ -83,6 +88,69 @@ func fatalf(format string, args ...any) {
8388
os.Exit(1)
8489
}
8590

91+
// applyManifestFlag scans args for a global --manifest <source> (or
92+
// --manifest=<source>) flag, applies it as the manifest override, and returns
93+
// args with the flag and its value removed so subcommand parsing is unaffected.
94+
// The flag may appear anywhere in args.
95+
func applyManifestFlag(args []string) ([]string, error) {
96+
var rest []string
97+
for i := 0; i < len(args); i++ {
98+
a := args[i]
99+
switch {
100+
case a == "--manifest":
101+
if i+1 >= len(args) {
102+
return nil, fmt.Errorf("--manifest requires a file path or URL")
103+
}
104+
if err := useManifestOverride(args[i+1]); err != nil {
105+
return nil, err
106+
}
107+
i++ // skip the value we just consumed
108+
case strings.HasPrefix(a, "--manifest="):
109+
if err := useManifestOverride(strings.TrimPrefix(a, "--manifest=")); err != nil {
110+
return nil, err
111+
}
112+
default:
113+
rest = append(rest, a)
114+
}
115+
}
116+
return rest, nil
117+
}
118+
119+
// useManifestOverride validates source, points the module manager at it for the
120+
// remainder of this invocation, and warns the user that the default registry is
121+
// being overridden.
122+
func useManifestOverride(source string) error {
123+
if err := validateManifestSource(source); err != nil {
124+
return err
125+
}
126+
manifestSource = source
127+
printManifestOverrideWarning(source)
128+
return nil
129+
}
130+
131+
// validateManifestSource ensures a custom manifest location points at a YAML
132+
// file. Both http(s) URLs and local filesystem paths are accepted, as long as
133+
// they end in .yaml or .yml (any query string or fragment on a URL is ignored).
134+
func validateManifestSource(source string) error {
135+
if strings.TrimSpace(source) == "" {
136+
return fmt.Errorf("--manifest requires a file path or URL")
137+
}
138+
lower := strings.ToLower(source)
139+
if i := strings.IndexAny(lower, "?#"); i >= 0 {
140+
lower = lower[:i]
141+
}
142+
if !strings.HasSuffix(lower, ".yaml") && !strings.HasSuffix(lower, ".yml") {
143+
return fmt.Errorf("manifest must be a .yaml file, got %q", source)
144+
}
145+
return nil
146+
}
147+
148+
// printManifestOverrideWarning warns that a non-default manifest is in use.
149+
func printManifestOverrideWarning(source string) {
150+
printWarning("using a custom module manifest instead of the default registry: %s", bold(source))
151+
fmt.Fprintln(os.Stderr, gray(" only use manifests from sources you trust; downloads are still SHA256-verified."))
152+
}
153+
86154
func printUsage() {
87155
fmt.Println()
88156
fmt.Println(cyan("GoMud Module Manager"))
@@ -105,6 +173,12 @@ func printUsage() {
105173
fmt.Printf(" %s %s\n", padRight(e.cmd, 22), e.desc)
106174
}
107175
fmt.Println()
176+
fmt.Println(bold("Global options:"))
177+
fmt.Printf(" %s %s\n", padRight(green("--manifest")+" <path|url>", 22),
178+
"Temporarily use an alternate module manifest (.yaml file or")
179+
fmt.Printf(" %s %s\n", padRight("", 22),
180+
"local path) instead of the default registry; for local testing")
181+
fmt.Println()
108182
fmt.Println(gray("Run without arguments (with a terminal) to start interactive mode."))
109183
fmt.Println()
110184
fmt.Println(bold("After installing or removing a module, rebuild the server:"))

0 commit comments

Comments
 (0)