diff --git a/README.md b/README.md
index d04581c..818097e 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,8 @@ USAGE:
COMMANDS:
unpack, u Unpacks files from a STAR file
+ pack, p Pack a folder into a STAR file
info, i Shows information about a STAR file
- help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help (default: false)
@@ -26,7 +26,7 @@ GLOBAL OPTIONS:
--version, -v print the version (default: false)
```
-### Unpack
+### Unpacking
```txt
NAME:
@@ -43,6 +43,24 @@ OPTIONS:
If no output directory is given, the file is extracted to the file name minus the extension plus "`_extracted`" (
e.g. `xPackmanJr_0.105.star` -> `xPackmanJr_0.105_extracted`). Same goes for packing (it will append `_packed.star`).
+### Packing
+
+**NOTE:** The correct order of the files is not implemented yet and there are many unknowns! See [issue #1](https://github.com/Brawl345/stargazer/issues/1).
+
+```txt
+NAME:
+ stargazer pack - Pack a folder into a STAR file
+
+USAGE:
+ stargazer pack [command options] [arguments...]
+
+OPTIONS:
+ --input value, -i value Path to a folder
+ --output value, -o value Output path of the STAR file. Defaults to '_packed.star'
+```
+
+If no output STAR file is given, the file will be created in the same directory as the stargazer binary with the name of the folder plus `_packed.star`.
+
### Info
```txt
diff --git a/cmd/stargazer/main.go b/cmd/stargazer/main.go
index 88fe432..fb4a114 100644
--- a/cmd/stargazer/main.go
+++ b/cmd/stargazer/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "bufio"
"fmt"
"log"
"os"
@@ -22,6 +23,7 @@ func main() {
Version: "2.0.0",
Suggest: true,
EnableBashCompletion: true,
+ HideHelpCommand: true,
Authors: []*cli.Author{
{
Name: "Brawl345",
@@ -58,6 +60,28 @@ func main() {
},
Action: unpack,
},
+ {
+ Name: "pack",
+ Aliases: []string{"p"},
+ Usage: "Pack a folder into a STAR file",
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "input",
+ Aliases: []string{"i"},
+ Required: true,
+ Usage: "Path to a folder",
+ Destination: &input,
+ },
+ &cli.StringFlag{
+ Name: "output",
+ Aliases: []string{"o"},
+ Required: false,
+ Usage: "Output path of the STAR file. Defaults to '_packed.star'",
+ Destination: &output,
+ },
+ },
+ Action: pack,
+ },
{
Name: "info",
Aliases: []string{"i"},
@@ -110,6 +134,49 @@ func unpack(_ *cli.Context) error {
return nil
}
+func pack(_ *cli.Context) error {
+ if output == "" {
+ output = fmt.Sprintf("%s_packed.star", filepath.Base(input))
+ }
+
+ if !quiet {
+ log.Printf("Will pack to '%s'", output)
+ }
+
+ if !quiet {
+ log.Printf("Reading '%s'...", input)
+ }
+
+ star, err := stargazer.NewSTARFileFromDirectory(input)
+
+ if err != nil {
+ return err
+ }
+
+ if !quiet {
+ log.Printf("Writing to '%s'...\n", output)
+ }
+
+ out, err := os.Create(output)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ writer := bufio.NewWriter(out)
+ _, err = star.WriteTo(writer)
+ if err != nil {
+ return err
+ }
+
+ err = writer.Flush()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
func info(_ *cli.Context) error {
star, err := stargazer.LoadSTARFromFile(input)
if err != nil {
diff --git a/pkg/stargazer/const.go b/pkg/stargazer/const.go
new file mode 100644
index 0000000..88c7352
--- /dev/null
+++ b/pkg/stargazer/const.go
@@ -0,0 +1,7 @@
+package stargazer
+
+import "math"
+
+const (
+ MaxFilenameSize = math.MaxUint8 - 8
+)
diff --git a/pkg/stargazer/errors.go b/pkg/stargazer/errors.go
index a7a9354..cf6dbc0 100644
--- a/pkg/stargazer/errors.go
+++ b/pkg/stargazer/errors.go
@@ -3,6 +3,7 @@ package stargazer
import (
"errors"
"fmt"
+ "math"
)
var ErrNoEntries = errors.New("invalid STAR file - no file entries found")
@@ -13,8 +14,40 @@ type (
Actual string
Filename string
}
+
+ ErrFilenameTooLong struct {
+ Filename string
+ }
+
+ ErrFileTooLarge struct {
+ Filename string
+ }
+
+ ErrNotAFile struct {
+ Filename string
+ }
+
+ ErrNotADirectory struct {
+ Path string
+ }
)
func (e ErrSHA1Mismatch) Error() string {
return fmt.Sprintf("SHA1 mismatch on file '%s': expected %s, actual %s", e.Filename, e.Expected, e.Actual)
}
+
+func (e ErrFilenameTooLong) Error() string {
+ return fmt.Sprintf("filename '%s' is too long, needs to be < %d characters.", e.Filename, MaxFilenameSize)
+}
+
+func (e ErrFileTooLarge) Error() string {
+ return fmt.Sprintf("file '%s' is too large, needs to be < %d bytes.", e.Filename, math.MaxUint32)
+}
+
+func (e ErrNotAFile) Error() string {
+ return fmt.Sprintf("'%s' is not a file.", e.Filename)
+}
+
+func (e ErrNotADirectory) Error() string {
+ return fmt.Sprintf("'%s' is not a directory.", e.Path)
+}
diff --git a/pkg/stargazer/star.go b/pkg/stargazer/star.go
deleted file mode 100644
index ff0c661..0000000
--- a/pkg/stargazer/star.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package stargazer
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-)
-
-type (
- Star struct {
- Entries []Entry
- }
-
- Entry struct {
- Header
- Content []byte
- SHA1 [20]byte
- }
-
- Header struct {
- Headersize uint8
- Padding1 uint8
- Filesize uint32
- FilenameSize uint8
- Padding2 uint8
- Filename string
- }
-)
-
-func (e *Entry) SHA1String() string {
- return fmt.Sprintf("%x", e.SHA1)
-}
-
-func (e *Entry) Unpack(outputDir string) error {
- fp := filepath.Join(outputDir, e.Filename)
- err := os.MkdirAll(filepath.Dir(fp), os.ModePerm)
- if err != nil {
- return err
- }
- f, err := os.Create(fp)
- if err != nil {
- return err
- }
- defer f.Close()
-
- _, err = f.Write(e.Content)
- if err != nil {
- return err
- }
- return nil
-}
-
-func (e *Entry) Info() string {
- var sb strings.Builder
-
- sb.WriteString(fmt.Sprintf("%s\n", e.Filename))
- sb.WriteString(fmt.Sprintf(" Filesize: %d bytes\n", e.Header.Filesize))
- sb.WriteString(fmt.Sprintf(" SHA1: %s\n", e.SHA1String()))
-
- return sb.String()
-}
-
-func (s *Star) Unpack(outputDir string) error {
- for _, e := range s.Entries {
- err := e.Unpack(outputDir)
- if err != nil {
- return err
- }
- }
- return nil
-}
-
-func (s *Star) Info() string {
- var sb strings.Builder
-
- var contentSize uint64
- for _, e := range s.Entries {
- contentSize += uint64(e.Header.Filesize)
- sb.WriteString(e.Info())
- sb.WriteString("\n")
- }
-
- sb.WriteString(fmt.Sprintf("Total contents: %d\n", len(s.Entries)))
- sb.WriteString(fmt.Sprintf("Total content size: %d bytes\n", contentSize))
-
- return sb.String()
-}
diff --git a/pkg/stargazer/stargazer.go b/pkg/stargazer/stargazer.go
index 88dd77b..2413a84 100644
--- a/pkg/stargazer/stargazer.go
+++ b/pkg/stargazer/stargazer.go
@@ -7,7 +7,31 @@ import (
"encoding/hex"
"fmt"
"io"
+ "math"
"os"
+ "path/filepath"
+ "strings"
+)
+
+type (
+ Star struct {
+ Entries []Entry
+ }
+
+ Entry struct {
+ Header
+ Filename string
+ Content []byte
+ SHA1 [20]byte
+ }
+
+ Header struct {
+ Headersize uint8
+ Padding1 uint8
+ Filesize uint32
+ FilenameSize uint8
+ Padding2 uint8
+ }
)
func parseEntry(file io.Reader) (*Entry, error) {
@@ -60,7 +84,7 @@ func parseEntry(file io.Reader) (*Entry, error) {
}
return nil, fmt.Errorf("error reading filename: %v", err)
}
- entry.Header.Filename = string(filename)
+ entry.Filename = string(filename)
entry.Content = make([]byte, entry.Header.Filesize)
err = binary.Read(file, binary.LittleEndian, &entry.Content)
@@ -85,7 +109,7 @@ func parseEntry(file io.Reader) (*Entry, error) {
if !bytes.Equal(calculatedHash, entry.SHA1[:]) {
return nil, ErrSHA1Mismatch{
- Filename: entry.Header.Filename,
+ Filename: entry.Filename,
Expected: hex.EncodeToString(entry.SHA1[:]),
Actual: hex.EncodeToString(calculatedHash),
}
@@ -94,15 +118,109 @@ func parseEntry(file io.Reader) (*Entry, error) {
return &entry, nil
}
-func LoadSTARFromFile(fp string) (*Star, error) {
+//NewSTARFileFromDirectory creates a new STAR from a given directory.
+func NewSTARFileFromDirectory(dir string) (*Star, error) {
+ // TODO: Order files with install.txt and metadata.txt at end
+
+ if !isDir(dir) {
+ return nil, ErrNotADirectory{
+ Path: dir,
+ }
+ }
+
+ star := &Star{}
+ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+ relativePath, err := filepath.Rel(dir, path)
+ if err != nil {
+ return err
+ }
+
+ entry, err := NewEntryFromFile(dir, relativePath)
+ if err != nil {
+ return err
+ }
+ star.Entries = append(star.Entries, *entry)
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return star, nil
+}
+
+//NewEntryFromFile creates a new STAR entry from a file.
+//Second parameter is the relative filename of the file in the archive.
+func NewEntryFromFile(dir string, f string) (*Entry, error) {
+ fp := filepath.Join(dir, f)
file, err := os.Open(fp)
if err != nil {
return nil, err
}
defer file.Close()
+
+ entry := &Entry{}
+
+ filename := strings.TrimPrefix(f, string(os.PathSeparator))
+ filename = strings.ReplaceAll(filename, "\\", "/")
+
+ if len(filename) > MaxFilenameSize {
+ return nil, ErrFilenameTooLong{
+ Filename: filename,
+ }
+ }
+
+ entry.Filename = filename
+ entry.Header.FilenameSize = uint8(len(entry.Filename))
+
+ entry.Content, err = io.ReadAll(file)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(entry.Content) > math.MaxUint32 {
+ return nil, ErrFileTooLarge{
+ Filename: filename,
+ }
+ }
+
+ entry.Header.Filesize = uint32(len(entry.Content))
+
+ entry.Header.Headersize = uint8(1 + 1 + 4 + 1 + 1 + len(entry.Filename))
+
+ h := sha1.New()
+ h.Write(entry.Content)
+ copy(entry.SHA1[:], h.Sum(nil))
+
+ return entry, nil
+}
+
+//LoadSTARFromFile loads a STAR file from a filepath.
+func LoadSTARFromFile(fp string) (*Star, error) {
+ file, err := os.Open(fp)
+ if err != nil {
+ return nil, err
+ }
+
+ defer file.Close()
+
+ if !isFile(fp) {
+ return nil, ErrNotAFile{
+ Filename: fp,
+ }
+ }
+
return LoadSTAR(file)
}
+//LoadSTAR loads a STAR file from an io.Reader.
func LoadSTAR(file io.Reader) (*Star, error) {
star := &Star{}
for {
@@ -122,3 +240,110 @@ func LoadSTAR(file io.Reader) (*Star, error) {
return star, nil
}
+
+//SHA1String returns the SHA1 of the file as a hex string.
+func (e *Entry) SHA1String() string {
+ return fmt.Sprintf("%x", e.SHA1)
+}
+
+//Unpack unpacks the entry to the given directory.
+func (e *Entry) Unpack(outputDir string) error {
+ fp := filepath.Join(outputDir, e.Filename)
+ err := os.MkdirAll(filepath.Dir(fp), os.ModePerm)
+ if err != nil {
+ return err
+ }
+ f, err := os.Create(fp)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = f.Write(e.Content)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+//WriteTo writes the entry to the given io.Writer.
+func (e *Entry) WriteTo(w io.Writer) (int64, error) {
+ var total int64
+ err := binary.Write(w, binary.LittleEndian, &e.Header)
+ if err != nil {
+ return 0, err
+ }
+ total += int64(e.Header.Headersize)
+
+ err = binary.Write(w, binary.LittleEndian, []byte(e.Filename))
+ if err != nil {
+ return 0, err
+ }
+ total += int64(e.Header.FilenameSize)
+
+ err = binary.Write(w, binary.LittleEndian, e.Content)
+ if err != nil {
+ return 0, err
+ }
+ total += int64(e.Header.Filesize)
+
+ err = binary.Write(w, binary.LittleEndian, e.SHA1)
+ if err != nil {
+ return 0, err
+ }
+ total += int64(20)
+
+ return total, nil
+}
+
+//Info returns a string with various information of the entry.
+func (e *Entry) Info() string {
+ var sb strings.Builder
+
+ sb.WriteString(fmt.Sprintf("%s\n", e.Filename))
+ sb.WriteString(fmt.Sprintf(" Filesize: %d bytes\n", e.Header.Filesize))
+ sb.WriteString(fmt.Sprintf(" SHA1: %s\n", e.SHA1String()))
+
+ return sb.String()
+}
+
+//Unpack unpacks all entries from the STAR to the given directory.
+func (s *Star) Unpack(outputDir string) error {
+ for _, e := range s.Entries {
+ err := e.Unpack(outputDir)
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+//WriteTo writes the STAR with all entries to the given io.Writer.
+func (s *Star) WriteTo(w io.Writer) (int64, error) {
+ var total int64
+ for _, e := range s.Entries {
+ n, err := e.WriteTo(w)
+ if err != nil {
+ return total, err
+ }
+ total += n
+ }
+ return total, nil
+}
+
+//Info returns a string with various information of all entries.
+func (s *Star) Info() string {
+ var sb strings.Builder
+
+ var contentSize uint64
+ for _, e := range s.Entries {
+ contentSize += uint64(e.Header.Filesize)
+ sb.WriteString(e.Info())
+ sb.WriteString("\n")
+ }
+
+ sb.WriteString(fmt.Sprintf("Total contents: %d\n", len(s.Entries)))
+ sb.WriteString(fmt.Sprintf("Total content size: %d bytes\n", contentSize))
+
+ return sb.String()
+}
diff --git a/pkg/stargazer/stargazer_test.go b/pkg/stargazer/stargazer_test.go
index 737d58d..c9783f3 100644
--- a/pkg/stargazer/stargazer_test.go
+++ b/pkg/stargazer/stargazer_test.go
@@ -1,6 +1,7 @@
package stargazer
import (
+ "errors"
"os"
"path/filepath"
"testing"
@@ -44,8 +45,8 @@ func TestLoadSTARFromFile(t *testing.T) {
return
}
- if got.Entries[0].Header.Filename != "NulledFile.rel" {
- t.Errorf("Expected filename of first entry to be 'NulledFile.rel', got '%s'", got.Entries[0].Header.Filename)
+ if got.Entries[0].Filename != "NulledFile.rel" {
+ t.Errorf("Expected filename of first entry to be 'NulledFile.rel', got '%s'", got.Entries[0].Filename)
return
}
@@ -90,8 +91,8 @@ func TestLoadSTARFromFile(t *testing.T) {
return
}
- if got.Entries[1].Header.Filename != "metadata.xml" {
- t.Errorf("Expected filename of second entry to be 'metadata.xml', got '%s'", got.Entries[1].Header.Filename)
+ if got.Entries[1].Filename != "metadata.xml" {
+ t.Errorf("Expected filename of second entry to be 'metadata.xml', got '%s'", got.Entries[1].Filename)
return
}
@@ -136,8 +137,8 @@ func TestLoadSTARFromFile(t *testing.T) {
return
}
- if got.Entries[2].Header.Filename != "install.txt" {
- t.Errorf("Expected filename of third entry to be 'install.txt', got '%s'", got.Entries[2].Header.Filename)
+ if got.Entries[2].Filename != "install.txt" {
+ t.Errorf("Expected filename of third entry to be 'install.txt', got '%s'", got.Entries[2].Filename)
return
}
@@ -157,7 +158,7 @@ func TestLoadSTARFromFile(t *testing.T) {
}
}
-func TestLoadSTARFromFileFail(t *testing.T) {
+func TestLoadSTARFromFileNotExisting(t *testing.T) {
_, err := LoadSTARFromFile("invalid.star")
if err == nil {
t.Errorf("Expected error, got nil")
@@ -165,6 +166,20 @@ func TestLoadSTARFromFileFail(t *testing.T) {
}
}
+func TestLoadSTARFromFileNotAFile(t *testing.T) {
+ _, err := LoadSTARFromFile(".")
+ if err == nil {
+ t.Errorf("Expected error, got nil")
+ return
+ }
+
+ var expectedErr ErrNotAFile
+ if !errors.As(err, &expectedErr) {
+ t.Errorf("Expected error to be of type ErrNotAFile, got %T", err)
+ return
+ }
+}
+
func TestStar_Unpack(t *testing.T) {
got, err := LoadSTARFromFile(filepath.Join("..", "..", "testdata", "testfile.star"))
if err != nil {
@@ -195,6 +210,136 @@ func TestStar_Unpack(t *testing.T) {
}
}
+func TestNewEntryFromFile(t *testing.T) {
+ got, err := NewEntryFromFile(filepath.Join("..", "..", "testdata", "unpacked"), filepath.Join("dummyfolder", "NulledFile.rel"))
+ if err != nil {
+ t.Error(err)
+ return
+ }
+
+ if got.Filename != "dummyfolder/NulledFile.rel" {
+ t.Errorf("Expected filename to be 'dummyfolder/NulledFile', got '%s'", got.Filename)
+ return
+ }
+
+ if got.Header.Headersize != 34 {
+ t.Errorf("Expected header size to be 34, got %d", got.Header.Headersize)
+ return
+ }
+
+ if got.Header.Padding1 != 0 {
+ t.Errorf("Expected first padding to be 0, got %d", got.Header.Padding1)
+ return
+ }
+
+ if got.Header.Filesize != 10928 {
+ t.Errorf("Expected filesize to be 10928, got %d", got.Header.Filesize)
+ return
+ }
+
+ if got.Header.FilenameSize != 26 {
+ t.Errorf("Expected filename size to be 26, got %d", got.Header.FilenameSize)
+ return
+ }
+
+ if got.Header.Padding2 != 0 {
+ t.Errorf("Expected second padding to be 0, got %d", got.Header.Padding2)
+ return
+ }
+
+ if got.SHA1String() != "3d433fcbe9585b05ea877814bad60774ff8a9e7c" {
+ t.Errorf("Expected SHA1 to be '3d433fcbe9585b05ea877814bad60774ff8a9e7c', got '%s'", got.SHA1String())
+ return
+ }
+
+ if got.Content == nil {
+ t.Errorf("Expected content to be non-nil")
+ return
+ }
+
+ if uint32(len(got.Content)) != got.Header.Filesize {
+ t.Errorf("Content size in header is set to %d bytes, but the file is actually %d bytes long", got.Header.Filesize, len(got.Content))
+ return
+ }
+}
+
+func TestNewSTARFileFromDirectory(t *testing.T) {
+ got, err := NewSTARFileFromDirectory(filepath.Join("..", "..", "testdata", "unpacked"))
+ if err != nil {
+ t.Error(err)
+ return
+ }
+
+ if len(got.Entries) != 3 {
+ t.Errorf("Expected 3 entries, got %d", len(got.Entries))
+ return
+ }
+
+ // TODO: Test entries when the correct order is implemented
+
+ // Check first entry
+ if got.Entries[0].Header.Headersize != 34 {
+ t.Errorf("Expected header size of first entry to be 34 bytes long, got %d", got.Entries[0].Header.Headersize)
+ return
+ }
+
+ if got.Entries[0].Header.Padding1 != 0 {
+ t.Errorf("Expected first padding of first entry to be 0, got %d", got.Entries[0].Header.Padding1)
+ return
+ }
+
+ if got.Entries[0].Header.Filesize != 10928 {
+ t.Errorf("Expected filesize of first entry to be 10928, got %d", got.Entries[0].Header.Filesize)
+ return
+ }
+
+ if got.Entries[0].Header.FilenameSize != 26 {
+ t.Errorf("Expected filename size of first entry to be 26, got %d", got.Entries[0].Header.FilenameSize)
+ return
+ }
+
+ if got.Entries[0].Header.Padding2 != 0 {
+ t.Errorf("Expected second padding of first entry to be 0, got %d", got.Entries[0].Header.Padding2)
+ return
+ }
+
+ if got.Entries[0].Filename != "dummyfolder/NulledFile.rel" {
+ t.Errorf("Expected filename of first entry to be 'dummyfolder/NulledFile.rel', got '%s'", got.Entries[0].Filename)
+ return
+ }
+
+ if got.Entries[0].Content == nil {
+ t.Errorf("Expected content of first entry to be non-nil")
+ return
+ }
+
+ if uint32(len(got.Entries[0].Content)) != got.Entries[0].Header.Filesize {
+ t.Errorf("Expected content of first entry to be %d bytes long, got %d", got.Entries[0].Header.Filesize, len(got.Entries[0].Content))
+ return
+ }
+
+ if got.Entries[0].SHA1String() != "3d433fcbe9585b05ea877814bad60774ff8a9e7c" {
+ t.Errorf("Expected SHA1 of first entry to be '3d433fcbe9585b05ea877814bad60774ff8a9e7c', got '%s'", got.Entries[0].SHA1String())
+ return
+ }
+}
+
+func TestNewSTARFileFromDirectoryNotADirectory(t *testing.T) {
+ _, err := NewSTARFileFromDirectory(filepath.Join("..", "..", "testdata", "install.txt"))
+ if err == nil {
+ t.Errorf("Expected error, got nil")
+ return
+ }
+
+ var expectedErr ErrNotADirectory
+ if !errors.As(err, &expectedErr) {
+ t.Errorf("Expected error to be of type ErrNotADirectory, got %T", err)
+ return
+ }
+}
+
+// TODO: More tests for failures (file size, file name size, etc.)
+
func fileExists(fileName string) bool {
if _, err := os.Stat(fileName); err == nil {
return true
diff --git a/pkg/stargazer/utils.go b/pkg/stargazer/utils.go
new file mode 100644
index 0000000..9600169
--- /dev/null
+++ b/pkg/stargazer/utils.go
@@ -0,0 +1,19 @@
+package stargazer
+
+import "os"
+
+func isFile(path string) bool {
+ st, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return st.Mode().IsRegular()
+}
+
+func isDir(path string) bool {
+ st, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return st.IsDir()
+}
diff --git a/testdata/unpacked/dummyfolder/NulledFile.rel b/testdata/unpacked/dummyfolder/NulledFile.rel
new file mode 100644
index 0000000..9517e58
Binary files /dev/null and b/testdata/unpacked/dummyfolder/NulledFile.rel differ
diff --git a/testdata/unpacked/install.txt b/testdata/unpacked/install.txt
new file mode 100644
index 0000000..f1bcf45
--- /dev/null
+++ b/testdata/unpacked/install.txt
@@ -0,0 +1,9 @@
+# --- Uninstall previous version
+RemovePackage %%%Name%%% %%%Major%%%
+DeleteDirectory %%%PackagesDir%%%/%%%Name%%%_%%%Major%%%
+
+# --- Install new version
+CreateDirectory %%%PackagesDir%%%/%%%Name%%%_%%%Major%%%
+CopyDirectory %%%DownloadDir%%%/%%%Name%%%_%%%Major%%%.%%%Minor%%% %%%PackagesDir%%%/%%%Name%%%_%%%Major%%%
+AddPackage %%%Name%%% %%%Version%%% %%%PackagesDir%%%/%%%Name%%%_%%%Major%%% flash_package
+
diff --git a/testdata/unpacked/metadata.xml b/testdata/unpacked/metadata.xml
new file mode 100644
index 0000000..ab3efbf
--- /dev/null
+++ b/testdata/unpacked/metadata.xml
@@ -0,0 +1,14 @@
+
+
+ 000
+ NulledFile
+
+ PSXJA3
+
+
+
+
+ 0
+ 100
+
+