Skip to content

Commit c12f153

Browse files
committed
zip restore
1 parent e762168 commit c12f153

5 files changed

Lines changed: 276 additions & 2 deletions

File tree

‎cmd/wasm/main.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ func registerCallbacks(api *api.Api) {
140140
return api.ExportZip(args[0].String())
141141
})
142142
}),
143+
"restoreZip": js.FuncOf(func(this js.Value, args []js.Value) any {
144+
return wrapPromise(func() (any, error) {
145+
data := make([]byte, args[0].Get("byteLength").Int())
146+
js.CopyBytesToGo(data, args[0])
147+
return nil, api.RestoreZip(data)
148+
})
149+
}),
143150
}),
144151
)
145152

‎e2e/zip_restore_test.go‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package e2e
2+
3+
import (
4+
"archive/zip"
5+
"bytes"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/git-calendar/core/pkg/core"
12+
"github.com/google/uuid"
13+
)
14+
15+
func TestRestoreZipRoundTrip(t *testing.T) {
16+
t.Setenv("HOME", t.TempDir())
17+
18+
const calendar = "restored-calendar"
19+
source := core.NewCore()
20+
if err := source.CreateCalendar(calendar, "password"); err != nil {
21+
t.Fatal(err)
22+
}
23+
tag, err := source.CreateTag(calendar, core.Tag{ID: uuid.New(), Name: "Work", Color: "blue"})
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
start := time.Date(2026, time.January, 1, 10, 0, 0, 0, time.UTC)
28+
event, err := source.CreateEvent(core.Event{
29+
ID: uuid.New(),
30+
Title: "Restored event",
31+
From: start,
32+
To: start.Add(time.Hour),
33+
Calendar: calendar,
34+
TagID: &tag.ID,
35+
})
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
40+
backup, err := source.ExportZip("")
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
45+
t.Setenv("HOME", t.TempDir())
46+
restored := core.NewCore()
47+
if err := restored.RestoreZip(backup); err != nil {
48+
t.Fatalf("RestoreZip failed: %v", err)
49+
}
50+
if err := restored.CreateCalendar("removed-by-restore", ""); err != nil {
51+
t.Fatal(err)
52+
}
53+
if err := restored.RestoreZip(backup); err != nil {
54+
t.Fatalf("second RestoreZip failed: %v", err)
55+
}
56+
57+
calendars, err := restored.ListCalendars()
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if len(calendars) != 1 || calendars[0].Name != calendar || !calendars[0].IsEncrypted() {
62+
t.Fatalf("restored calendars = %+v", calendars)
63+
}
64+
if len(calendars[0].Tags) != 1 || calendars[0].Tags[0].ID != tag.ID {
65+
t.Fatalf("restored tags = %+v", calendars[0].Tags)
66+
}
67+
68+
got, err := restored.GetEvent(event.ID)
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
if got.Title != event.Title || got.TagID == nil || *got.TagID != tag.ID {
73+
t.Fatalf("restored event = %+v", got)
74+
}
75+
}
76+
77+
func TestRestoreZipRejectsTraversalBeforeWriting(t *testing.T) {
78+
home := t.TempDir()
79+
t.Setenv("HOME", home)
80+
c := core.NewCore()
81+
if err := c.CreateCalendar("preserved", ""); err != nil {
82+
t.Fatal(err)
83+
}
84+
85+
if err := c.RestoreZip(traversalZip(t)); err == nil {
86+
t.Fatal("expected traversal archive to fail")
87+
}
88+
calendars, err := c.ListCalendars()
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
if len(calendars) != 1 || calendars[0].Name != "preserved" {
93+
t.Fatalf("invalid restore changed calendars: %+v", calendars)
94+
}
95+
if _, err := os.Stat(filepath.Join(home, "outside")); !os.IsNotExist(err) {
96+
t.Fatalf("traversal path exists or could not be checked: %v", err)
97+
}
98+
}
99+
100+
func traversalZip(t *testing.T) []byte {
101+
t.Helper()
102+
103+
var data bytes.Buffer
104+
func() {
105+
zw := zip.NewWriter(&data)
106+
defer zw.Close()
107+
108+
valid, err := zw.Create("calendar/events/event.json")
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
if _, err := valid.Write([]byte("{}")); err != nil {
113+
t.Fatal(err)
114+
}
115+
outside, err := zw.Create("../outside")
116+
if err != nil {
117+
t.Fatal(err)
118+
}
119+
if _, err := outside.Write([]byte("bad")); err != nil {
120+
t.Fatal(err)
121+
}
122+
}()
123+
return data.Bytes()
124+
}

‎pkg/api/api.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ func (a *Api) SyncAll() error { return a.inner.SyncAll() }
7070
// URL-backed calendars cannot be exported individually.
7171
func (a *Api) ExportZip(calendar string) ([]byte, error) { return a.inner.ExportZip(calendar) }
7272

73+
// RestoreZip replaces all persisted data with a full ZIP backup.
74+
func (a *Api) RestoreZip(data []byte) error { return a.inner.RestoreZip(data) }
75+
7376
// ------------------------------------------ JSON wrapper methods ------------------------------------------
7477

7578
// ImportICalURL imports an iCalendar feed URL under the given name.

‎pkg/core/core.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/git-calendar/core/pkg/filesystem"
1414
"github.com/git-calendar/core/pkg/gitmerge"
1515
"github.com/go-git/go-billy/v5"
16+
gogitutil "github.com/go-git/go-billy/v5/util"
1617
gogit "github.com/go-git/go-git/v5"
1718
"github.com/go-git/go-git/v5/plumbing"
1819
"github.com/go-git/go-git/v5/plumbing/cache"
@@ -194,8 +195,48 @@ func (c *Core) ExportZip(calendar string) ([]byte, error) {
194195
return buf.Bytes(), nil
195196
}
196197

198+
// RestoreZip replaces all persisted data with a full backup.
199+
func (c *Core) RestoreZip(data []byte) error {
200+
if err := export.ValidateZip(data); err != nil {
201+
return err
202+
}
203+
if err := c.clearPersistedData(); err != nil {
204+
return fmt.Errorf("clear existing data: %w", err)
205+
}
206+
c.resetCore()
207+
208+
if err := export.Unzip(c.fs, data); err != nil {
209+
if cleanupErr := c.clearPersistedData(); cleanupErr != nil {
210+
return errors.Join(err, fmt.Errorf("cleanup after failed restore: %w", cleanupErr))
211+
}
212+
return err
213+
}
214+
if err := c.LoadCalendars(); err != nil {
215+
c.resetCore()
216+
if cleanupErr := c.clearPersistedData(); cleanupErr != nil {
217+
return errors.Join(err, fmt.Errorf("cleanup after failed restore: %w", cleanupErr))
218+
}
219+
return fmt.Errorf("load restored data: %w", err)
220+
}
221+
return nil
222+
}
223+
197224
// ------------------------------------------------ Helpers -------------------------------------------------
198225

226+
func (c *Core) clearPersistedData() error {
227+
entries, err := c.fs.ReadDir(".")
228+
if err != nil {
229+
return err
230+
}
231+
var result error
232+
for _, entry := range entries {
233+
if err := gogitutil.RemoveAll(c.fs, entry.Name()); err != nil {
234+
result = errors.Join(result, err)
235+
}
236+
}
237+
return result
238+
}
239+
199240
// resetCore clears and reinitializes the in-memory indexes.
200241
func (c *Core) resetCore() {
201242
c.intervalTree = NewIntervalTree()

‎pkg/export/zip.go‎

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
// Package export writes filesystem data to portable archive formats.
1+
// Package export reads and writes filesystem data in portable archive formats.
22
package export
33

44
import (
55
"archive/zip"
6+
"bytes"
7+
"fmt"
68
"io"
79
"os"
810
"path"
@@ -28,7 +30,104 @@ func Zip(fs billy.Filesystem, w io.Writer) error {
2830
}
2931
}
3032

31-
return zw.Close()
33+
return nil
34+
}
35+
36+
// ValidateZip checks that data contains a safe, supported ZIP archive.
37+
func ValidateZip(data []byte) error {
38+
_, err := validatedZip(data)
39+
return err
40+
}
41+
42+
// Unzip restores a validated ZIP archive into fs.
43+
func Unzip(fs billy.Filesystem, data []byte) error {
44+
zr, err := validatedZip(data)
45+
if err != nil {
46+
return err
47+
}
48+
49+
for _, file := range zr.File {
50+
name, _ := validZipPath(file.Name)
51+
if file.FileInfo().IsDir() {
52+
if err := fs.MkdirAll(name, directoryMode(file.Mode())); err != nil {
53+
return fmt.Errorf("create directory %q: %w", name, err)
54+
}
55+
continue
56+
}
57+
if err := extractZipFile(fs, file, name); err != nil {
58+
return err
59+
}
60+
}
61+
return nil
62+
}
63+
64+
func validatedZip(data []byte) (*zip.Reader, error) {
65+
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
66+
if err != nil {
67+
return nil, fmt.Errorf("open ZIP: %w", err)
68+
}
69+
70+
seen := make(map[string]struct{}, len(zr.File))
71+
for _, file := range zr.File {
72+
name, err := validZipPath(file.Name)
73+
if err != nil {
74+
return nil, err
75+
}
76+
if _, duplicate := seen[name]; duplicate {
77+
return nil, fmt.Errorf("duplicate ZIP path %q", name)
78+
}
79+
seen[name] = struct{}{}
80+
if !file.FileInfo().IsDir() && !file.Mode().IsRegular() {
81+
return nil, fmt.Errorf("unsupported ZIP entry %q", file.Name)
82+
}
83+
}
84+
return zr, nil
85+
}
86+
87+
func validZipPath(name string) (string, error) {
88+
trimmed := strings.TrimSuffix(name, "/")
89+
cleaned := path.Clean(trimmed)
90+
if trimmed == "" || cleaned != trimmed || path.IsAbs(cleaned) || cleaned == ".." ||
91+
strings.HasPrefix(cleaned, "../") || strings.ContainsAny(cleaned, `\:`) {
92+
return "", fmt.Errorf("invalid ZIP path %q", name)
93+
}
94+
return cleaned, nil
95+
}
96+
97+
func extractZipFile(fs billy.Filesystem, entry *zip.File, name string) error {
98+
if dir := path.Dir(name); dir != "." {
99+
if err := fs.MkdirAll(dir, 0o755); err != nil {
100+
return fmt.Errorf("create directory %q: %w", dir, err)
101+
}
102+
}
103+
104+
source, err := entry.Open()
105+
if err != nil {
106+
return fmt.Errorf("open ZIP entry %q: %w", name, err)
107+
}
108+
defer source.Close()
109+
110+
mode := entry.Mode().Perm()
111+
if mode == 0 {
112+
mode = 0o644
113+
}
114+
destination, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
115+
if err != nil {
116+
return fmt.Errorf("create restored file %q: %w", name, err)
117+
}
118+
defer destination.Close()
119+
120+
if _, err := io.Copy(destination, source); err != nil {
121+
return fmt.Errorf("restore file %q: %w", name, err)
122+
}
123+
return nil
124+
}
125+
126+
func directoryMode(mode os.FileMode) os.FileMode {
127+
if mode = mode.Perm(); mode == 0 {
128+
return 0o755
129+
}
130+
return mode
32131
}
33132

34133
type fileEntry struct {

0 commit comments

Comments
 (0)