diff --git a/core/hash.go b/core/hash.go index 46ce5d9..7e08445 100644 --- a/core/hash.go +++ b/core/hash.go @@ -5,23 +5,44 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/sha512" - "errors" + "encoding/binary" + "encoding/hex" + "fmt" + "github.com/packwiz/packwiz/curseforge/murmur2" "hash" + "strconv" "strings" ) // GetHashImpl gets an implementation of hash.Hash for the given hash type string -func GetHashImpl(hashType string) (hash.Hash, error) { +func GetHashImpl(hashType string) (hash.Hash, HashStringer, error) { switch strings.ToLower(hashType) { case "sha1": - return sha1.New(), nil + return sha1.New(), hexStringer{}, nil case "sha256": - return sha256.New(), nil + return sha256.New(), hexStringer{}, nil case "sha512": - return sha512.New(), nil + return sha512.New(), hexStringer{}, nil case "md5": - return md5.New(), nil + return md5.New(), hexStringer{}, nil + case "murmur2": + return murmur2.New(), numberStringer{}, nil } - // TODO: implement murmur2 - return nil, errors.New("hash implementation not found") + return nil, nil, fmt.Errorf("hash implementation %s not found", hashType) +} + +type HashStringer interface { + HashToString([]byte) string +} + +type hexStringer struct{} + +func (hexStringer) HashToString(data []byte) string { + return hex.EncodeToString(data) +} + +type numberStringer struct{} + +func (numberStringer) HashToString(data []byte) string { + return strconv.FormatUint(uint64(binary.BigEndian.Uint32(data)), 10) } diff --git a/core/index.go b/core/index.go index 1b6bafa..1aea382 100644 --- a/core/index.go +++ b/core/index.go @@ -1,7 +1,6 @@ package core import ( - "encoding/hex" "errors" "io" "os" @@ -133,7 +132,7 @@ func (in *Index) updateFile(path string) error { // Hash usage strategy (may change): // Just use SHA256, overwrite existing hash regardless of what it is // May update later to continue using the same hash that was already being used - h, err := GetHashImpl("sha256") + h, stringer, err := GetHashImpl("sha256") if err != nil { _ = f.Close() return err @@ -146,7 +145,7 @@ func (in *Index) updateFile(path string) error { if err != nil { return err } - hashString = hex.EncodeToString(h.Sum(nil)) + hashString = stringer.HashToString(h.Sum(nil)) } mod := false @@ -348,7 +347,7 @@ func (in Index) SaveFile(f IndexFile, dest io.Writer) error { if err != nil { return err } - h, err := GetHashImpl(hashFormat) + h, stringer, err := GetHashImpl(hashFormat) if err != nil { return err } @@ -359,7 +358,7 @@ func (in Index) SaveFile(f IndexFile, dest io.Writer) error { return err } - calculatedHash := hex.EncodeToString(h.Sum(nil)) + calculatedHash := stringer.HashToString(h.Sum(nil)) if calculatedHash != f.Hash && !viper.GetBool("no-internal-hashes") { return errors.New("hash of saved file is invalid") } diff --git a/core/mod.go b/core/mod.go index d3247e7..22d25de 100644 --- a/core/mod.go +++ b/core/mod.go @@ -1,7 +1,6 @@ package core import ( - "encoding/hex" "errors" "fmt" "io" @@ -90,7 +89,7 @@ func (m Mod) Write() (string, string, error) { } } - h, err := GetHashImpl("sha256") + h, stringer, err := GetHashImpl("sha256") if err != nil { _ = f.Close() return "", "", err @@ -101,7 +100,7 @@ func (m Mod) Write() (string, string, error) { // Disable indentation enc.Indent = "" err = enc.Encode(m) - hashString := hex.EncodeToString(h.Sum(nil)) + hashString := stringer.HashToString(h.Sum(nil)) if err != nil { _ = f.Close() return "sha256", hashString, err @@ -135,7 +134,7 @@ func (m Mod) DownloadFile(dest io.Writer) error { _ = resp.Body.Close() return errors.New("invalid status code " + strconv.Itoa(resp.StatusCode)) } - h, err := GetHashImpl(m.Download.HashFormat) + h, stringer, err := GetHashImpl(m.Download.HashFormat) if err != nil { return err } @@ -146,7 +145,7 @@ func (m Mod) DownloadFile(dest io.Writer) error { return err } - calculatedHash := hex.EncodeToString(h.Sum(nil)) + calculatedHash := stringer.HashToString(h.Sum(nil)) // Check if the hash of the downloaded file matches the expected hash. if calculatedHash != m.Download.Hash { diff --git a/core/pack.go b/core/pack.go index dc868f2..f105222 100644 --- a/core/pack.go +++ b/core/pack.go @@ -1,7 +1,6 @@ package core import ( - "encoding/hex" "errors" "fmt" "io" @@ -116,7 +115,7 @@ func (pack *Pack) UpdateIndexHash() error { // Hash usage strategy (may change): // Just use SHA256, overwrite existing hash regardless of what it is // May update later to continue using the same hash that was already being used - h, err := GetHashImpl("sha256") + h, stringer, err := GetHashImpl("sha256") if err != nil { _ = f.Close() return err @@ -125,7 +124,7 @@ func (pack *Pack) UpdateIndexHash() error { _ = f.Close() return err } - hashString := hex.EncodeToString(h.Sum(nil)) + hashString := stringer.HashToString(h.Sum(nil)) pack.Index.HashFormat = "sha256" pack.Index.Hash = hashString diff --git a/curseforge/murmur2/hash.go b/curseforge/murmur2/hash.go new file mode 100644 index 0000000..e4c7c96 --- /dev/null +++ b/curseforge/murmur2/hash.go @@ -0,0 +1,54 @@ +package murmur2 + +import ( + "encoding/binary" + "github.com/aviddiviner/go-murmur" + "hash" +) + +func New() hash.Hash32 { + return &Murmur2CF{buf: make([]byte, 0)} +} + +type Murmur2CF struct { + // Can't be done incrementally, since it is seeded with the length of the input! + buf []byte +} + +func (m *Murmur2CF) Write(p []byte) (n int, err error) { + for _, b := range p { + if !isWhitespaceCharacter(b) { + m.buf = append(m.buf, b) + } + } + return len(p), nil +} + +// CF modification: strips whitespace characters +func isWhitespaceCharacter(b byte) bool { + return b == 9 || b == 10 || b == 13 || b == 32 +} + +func (m *Murmur2CF) Sum(b []byte) []byte { + if b == nil { + b = make([]byte, 4) + } + binary.BigEndian.PutUint32(b, murmur.MurmurHash2(m.buf, 1)) + return b +} + +func (m *Murmur2CF) Reset() { + m.buf = make([]byte, 0) +} + +func (m *Murmur2CF) Size() int { + return 4 +} + +func (m *Murmur2CF) BlockSize() int { + return 4 +} + +func (m *Murmur2CF) Sum32() uint32 { + return binary.BigEndian.Uint32(m.Sum(nil)) +} diff --git a/modrinth/export.go b/modrinth/export.go new file mode 100644 index 0000000..8032c88 --- /dev/null +++ b/modrinth/export.go @@ -0,0 +1,243 @@ +package modrinth + +import ( + "archive/zip" + "encoding/json" + "fmt" + "github.com/spf13/viper" + "os" + "path/filepath" + + "github.com/packwiz/packwiz/core" + "github.com/spf13/cobra" +) + +// exportCmd represents the export command +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export the current modpack into a .mrpack for Modrinth", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Loading modpack...") + pack, err := core.LoadPack() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + index, err := pack.LoadIndex() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + // Do a refresh to ensure files are up to date + err = index.Refresh() + if err != nil { + fmt.Println(err) + return + } + err = index.Write() + if err != nil { + fmt.Println(err) + return + } + err = pack.UpdateIndexHash() + if err != nil { + fmt.Println(err) + return + } + err = pack.Write() + if err != nil { + fmt.Println(err) + return + } + + // TODO: should index just expose indexPath itself, through a function? + indexPath := filepath.Join(filepath.Dir(viper.GetString("pack-file")), filepath.FromSlash(pack.Index.File)) + + mods := loadMods(index) + + var fileName = pack.GetPackName() + ".mrpack" + expFile, err := os.Create(fileName) + if err != nil { + fmt.Printf("Failed to create zip: %s\n", err.Error()) + os.Exit(1) + } + exp := zip.NewWriter(expFile) + + // Add an overrides folder even if there are no files to go in it + _, err = exp.Create("overrides/") + if err != nil { + fmt.Printf("Failed to add overrides folder: %s\n", err.Error()) + os.Exit(1) + } + + // TODO: cache these (ideally with changes to pack format) + fmt.Println("Retrieving SHA1 hashes for external mods...") + sha1Hashes := make([]string, len(mods)) + for i, mod := range mods { + if mod.Download.HashFormat == "sha1" { + sha1Hashes[i] = mod.Download.Hash + } else { + // Hash format for this mod isn't SHA1 - and the Modrinth pack format requires it; so get it by downloading the file + h, stringer, err := core.GetHashImpl("sha1") + if err != nil { + panic("Failed to get sha1 hash implementation") + } + err = mod.DownloadFile(h) + if err != nil { + fmt.Printf("Error downloading mod file %s: %s\n", mod.Download.URL, err.Error()) + // TODO: exit(1)? + continue + } + sha1Hashes[i] = stringer.HashToString(h.Sum(nil)) + fmt.Printf("Retrieved SHA1 hash for %s successfully\n", mod.Download.URL) + } + } + + manifestFile, err := exp.Create("modrinth.index.json") + if err != nil { + _ = exp.Close() + _ = expFile.Close() + fmt.Println("Error creating manifest: " + err.Error()) + os.Exit(1) + } + + manifestFiles := make([]PackFile, len(mods)) + for i, mod := range mods { + pathForward, err := filepath.Rel(filepath.Dir(indexPath), mod.GetDestFilePath()) + if err != nil { + fmt.Printf("Error resolving mod file: %s\n", err.Error()) + // TODO: exit(1)? + continue + } + + path := filepath.ToSlash(pathForward) + + hashes := make(map[string]string) + hashes["sha1"] = sha1Hashes[i] + + // Create env options based on configured optional/side + var envInstalled string + if mod.Option != nil && mod.Option.Optional { + envInstalled = "optional" + } else { + envInstalled = "required" + } + var clientEnv, serverEnv string + if mod.Side == core.UniversalSide { + clientEnv = envInstalled + serverEnv = envInstalled + } else if mod.Side == core.ClientSide { + clientEnv = envInstalled + serverEnv = "unsupported" + } else if mod.Side == core.ServerSide { + clientEnv = "unsupported" + serverEnv = envInstalled + } + + manifestFiles[i] = PackFile{ + Path: path, + Hashes: hashes, + Env: &struct { + Client string `json:"client"` + Server string `json:"server"` + }{Client: clientEnv, Server: serverEnv}, + Downloads: []string{mod.Download.URL}, + } + } + + dependencies := make(map[string]string) + dependencies["minecraft"], err = pack.GetMCVersion() + if err != nil { + _ = exp.Close() + _ = expFile.Close() + fmt.Println("Error creating manifest: " + err.Error()) + os.Exit(1) + } + if fabricVersion, ok := pack.Versions["fabric"]; ok { + dependencies["fabric-loader"] = fabricVersion + } else if forgeVersion, ok := pack.Versions["forge"]; ok { + dependencies["forge"] = forgeVersion + } + + manifest := Pack{ + FormatVersion: 1, + Game: "minecraft", + VersionID: pack.Version, + Name: pack.Name, + Files: manifestFiles, + Dependencies: dependencies, + } + + w := json.NewEncoder(manifestFile) + w.SetIndent("", " ") // Documentation uses 4 spaces + err = w.Encode(manifest) + if err != nil { + _ = exp.Close() + _ = expFile.Close() + fmt.Println("Error writing manifest: " + err.Error()) + os.Exit(1) + } + + for _, v := range index.Files { + if !v.MetaFile { + // Save all non-metadata files into the zip + path, err := filepath.Rel(filepath.Dir(indexPath), index.GetFilePath(v)) + if err != nil { + fmt.Printf("Error resolving file: %s\n", err.Error()) + // TODO: exit(1)? + continue + } + file, err := exp.Create(filepath.ToSlash(filepath.Join("overrides", path))) + if err != nil { + fmt.Printf("Error creating file: %s\n", err.Error()) + // TODO: exit(1)? + continue + } + err = index.SaveFile(v, file) + if err != nil { + fmt.Printf("Error copying file: %s\n", err.Error()) + // TODO: exit(1)? + continue + } + } + } + + err = exp.Close() + if err != nil { + fmt.Println("Error writing export file: " + err.Error()) + os.Exit(1) + } + err = expFile.Close() + if err != nil { + fmt.Println("Error writing export file: " + err.Error()) + os.Exit(1) + } + + fmt.Println("Modpack exported to " + fileName) + fmt.Println("Make sure you remove this file before running packwiz refresh, or add it to .packwizignore") + }, +} + +func loadMods(index core.Index) []core.Mod { + modPaths := index.GetAllMods() + mods := make([]core.Mod, len(modPaths)) + i := 0 + fmt.Println("Reading mod files...") + for _, v := range modPaths { + modData, err := core.LoadMod(v) + if err != nil { + fmt.Printf("Error reading mod file %s: %s\n", v, err.Error()) + // TODO: exit(1)? + continue + } + + mods[i] = modData + i++ + } + return mods[:i] +} + +func init() { + modrinthCmd.AddCommand(exportCmd) +} diff --git a/modrinth/pack.go b/modrinth/pack.go new file mode 100644 index 0000000..dba40c1 --- /dev/null +++ b/modrinth/pack.go @@ -0,0 +1,22 @@ +package modrinth + +type Pack struct { + FormatVersion int `json:"formatVersion"` + Game string `json:"game"` + VersionID string `json:"versionId"` + Name string `json:"name"` + // TODO: implement Summary + // Summary string `json:"summary"` + Files []PackFile `json:"files"` + Dependencies map[string]string `json:"dependencies"` +} + +type PackFile struct { + Path string `json:"path"` + Hashes map[string]string `json:"hashes"` + Env *struct { + Client string `json:"client"` + Server string `json:"server"` + } `json:"env"` + Downloads []string `json:"downloads"` +}