Update mit Guacamole-Extension
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/example/sessionguard/internal/model"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
Files int `json:"files"`
|
||||
Dirs int `json:"dirs"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Version int `json:"version"`
|
||||
SID string `json:"sid"`
|
||||
User string `json:"user"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Folders []model.ProfileFolder `json:"folders"`
|
||||
Stats Stats `json:"stats"`
|
||||
}
|
||||
|
||||
func Backup(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int) (Stats, error) {
|
||||
return BackupGuarded(profileRoot, storeRoot, sid, user, folders, keepVersions, nil)
|
||||
}
|
||||
|
||||
// BackupGuarded copies a complete staging snapshot and calls activationGuard immediately
|
||||
// before replacing current. A guard failure leaves the previous current snapshot untouched.
|
||||
func BackupGuarded(profileRoot, storeRoot, sid, user string, folders []model.ProfileFolder, keepVersions int, activationGuard func() error) (Stats, error) {
|
||||
var total Stats
|
||||
if strings.TrimSpace(storeRoot) == "" {
|
||||
return total, errors.New("profile store_root is empty")
|
||||
}
|
||||
if strings.TrimSpace(sid) == "" {
|
||||
return total, errors.New("profile SID is empty")
|
||||
}
|
||||
if len(folders) == 0 {
|
||||
return total, errors.New("no profile folders configured")
|
||||
}
|
||||
userRoot := filepath.Join(storeRoot, safeSID(sid))
|
||||
if err := os.MkdirAll(userRoot, 0o700); err != nil {
|
||||
return total, fmt.Errorf("create profile store: %w", err)
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405.000000000Z")
|
||||
staging := filepath.Join(userRoot, ".staging-"+stamp)
|
||||
if err := os.MkdirAll(staging, 0o700); err != nil {
|
||||
return total, err
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = os.RemoveAll(staging)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, folder := range folders {
|
||||
rel, err := cleanRelative(folder.Path)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("profile folder %q: %w", folder.Path, err)
|
||||
}
|
||||
src := filepath.Join(profileRoot, rel)
|
||||
dst := filepath.Join(staging, rel)
|
||||
st, err := copyTree(src, dst, folder.ExcludeGlobs)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// A configured application folder may legitimately not exist for every user.
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("backup %q: %w", folder.Path, err)
|
||||
}
|
||||
total.Files += st.Files
|
||||
total.Dirs += st.Dirs
|
||||
total.Bytes += st.Bytes
|
||||
}
|
||||
manifest := Manifest{Version: 1, SID: sid, User: user, CreatedAt: time.Now().UTC(), Folders: folders, Stats: total}
|
||||
b, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, ".sessionguard-manifest.json"), b, 0o600); err != nil {
|
||||
return total, fmt.Errorf("write manifest: %w", err)
|
||||
}
|
||||
|
||||
if activationGuard != nil {
|
||||
if err := activationGuard(); err != nil {
|
||||
return total, fmt.Errorf("snapshot activation guard: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
current := filepath.Join(userRoot, "current")
|
||||
archive := ""
|
||||
if _, err := os.Stat(current); err == nil {
|
||||
if keepVersions > 0 {
|
||||
history := filepath.Join(userRoot, "history")
|
||||
if err := os.MkdirAll(history, 0o700); err != nil {
|
||||
return total, err
|
||||
}
|
||||
archive = filepath.Join(history, stamp)
|
||||
if err := os.Rename(current, archive); err != nil {
|
||||
return total, fmt.Errorf("archive previous profile snapshot: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Keep a temporary rollback copy until the new snapshot is active.
|
||||
archive = filepath.Join(userRoot, ".previous-"+stamp)
|
||||
if err := os.Rename(current, archive); err != nil {
|
||||
return total, fmt.Errorf("stage previous profile snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return total, err
|
||||
}
|
||||
if err := os.Rename(staging, current); err != nil {
|
||||
if archive != "" {
|
||||
_ = os.Rename(archive, current)
|
||||
}
|
||||
return total, fmt.Errorf("activate profile snapshot: %w", err)
|
||||
}
|
||||
if keepVersions == 0 && archive != "" {
|
||||
_ = os.RemoveAll(archive)
|
||||
}
|
||||
ok = true
|
||||
if keepVersions > 0 {
|
||||
if err := pruneHistory(filepath.Join(userRoot, "history"), keepVersions); err != nil {
|
||||
return total, fmt.Errorf("prune profile history: %w", err)
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func Restore(profileRoot, storeRoot, sid string, folders []model.ProfileFolder) (Stats, bool, error) {
|
||||
var total Stats
|
||||
if strings.TrimSpace(storeRoot) == "" {
|
||||
return total, false, errors.New("profile store_root is empty")
|
||||
}
|
||||
current := filepath.Join(storeRoot, safeSID(sid), "current")
|
||||
if _, err := os.Stat(current); errors.Is(err, os.ErrNotExist) {
|
||||
return total, false, nil
|
||||
} else if err != nil {
|
||||
return total, false, err
|
||||
}
|
||||
for _, folder := range folders {
|
||||
rel, err := cleanRelative(folder.Path)
|
||||
if err != nil {
|
||||
return total, true, fmt.Errorf("profile folder %q: %w", folder.Path, err)
|
||||
}
|
||||
src := filepath.Join(current, rel)
|
||||
dst := filepath.Join(profileRoot, rel)
|
||||
st, err := copyTree(src, dst, folder.ExcludeGlobs)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return total, true, fmt.Errorf("restore %q: %w", folder.Path, err)
|
||||
}
|
||||
total.Files += st.Files
|
||||
total.Dirs += st.Dirs
|
||||
total.Bytes += st.Bytes
|
||||
}
|
||||
return total, true, nil
|
||||
}
|
||||
|
||||
func cleanRelative(v string) (string, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
// Normalize both separators so validation is identical on Windows and in tests.
|
||||
normalized := strings.ReplaceAll(v, `\`, "/")
|
||||
if strings.HasPrefix(normalized, "/") || strings.Contains(normalized, ":") {
|
||||
return "", errors.New("path must be relative to the user profile")
|
||||
}
|
||||
cleanSlash := path.Clean(normalized)
|
||||
if cleanSlash == "." || cleanSlash == ".." || strings.HasPrefix(cleanSlash, "../") {
|
||||
return "", errors.New("path escapes the user profile")
|
||||
}
|
||||
return filepath.FromSlash(cleanSlash), nil
|
||||
}
|
||||
|
||||
func safeSID(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func copyTree(src, dst string, excludes []string) (Stats, error) {
|
||||
var stats Stats
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return stats, fmt.Errorf("refusing symbolic link/reparse-point root %s", src)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
if err := copyFile(src, dst, info); err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.Files = 1
|
||||
stats.Bytes = info.Size()
|
||||
return stats, nil
|
||||
}
|
||||
root := src
|
||||
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Dirs++
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
if excluded(relSlash, excludes) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(target, fi.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Dirs++
|
||||
return nil
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if err := copyFile(p, target, fi); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.Files++
|
||||
stats.Bytes += fi.Size()
|
||||
return nil
|
||||
})
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, info os.FileInfo) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := dst + ".sessionguard-tmp"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return closeErr
|
||||
}
|
||||
_ = os.Chtimes(tmp, info.ModTime(), info.ModTime())
|
||||
if err := replaceFile(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceFile avoids the destructive "remove destination and hope Rename works"
|
||||
// pattern that is especially risky on Windows. If a destination exists it is first
|
||||
// moved aside; a failed activation restores the previous file.
|
||||
func replaceFile(tmp, dst string) error {
|
||||
if _, err := os.Lstat(dst); errors.Is(err, os.ErrNotExist) {
|
||||
return os.Rename(tmp, dst)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
old := dst + ".sessionguard-old"
|
||||
_ = os.Remove(old)
|
||||
if err := os.Rename(dst, old); err != nil {
|
||||
return fmt.Errorf("stage existing destination: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
if rollbackErr := os.Rename(old, dst); rollbackErr != nil {
|
||||
return fmt.Errorf("activate replacement: %v; rollback failed: %w", err, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("activate replacement: %w", err)
|
||||
}
|
||||
if err := os.Remove(old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("remove replaced destination: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func excluded(rel string, patterns []string) bool {
|
||||
rel = strings.TrimPrefix(filepath.ToSlash(rel), "./")
|
||||
for _, raw := range patterns {
|
||||
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(p, "/**") {
|
||||
prefix := strings.TrimSuffix(p, "/**")
|
||||
if rel == prefix || strings.HasPrefix(rel, prefix+"/") {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ok, _ := path.Match(p, rel); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pruneHistory(dir string, keep int) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if keep >= len(names) {
|
||||
return nil
|
||||
}
|
||||
for _, name := range names[keep:] {
|
||||
if err := os.RemoveAll(filepath.Join(dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/sessionguard/internal/model"
|
||||
)
|
||||
|
||||
func TestBackupRestoreAndExclude(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
profileA := filepath.Join(root, "profile-a")
|
||||
store := filepath.Join(root, "store")
|
||||
profileB := filepath.Join(root, "profile-b")
|
||||
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "settings.json"), "v1")
|
||||
mustWrite(t, filepath.Join(profileA, "AppData", "Roaming", "Example", "Cache", "skip.bin"), "skip")
|
||||
folders := []model.ProfileFolder{{Path: "AppData/Roaming/Example", ExcludeGlobs: []string{"Cache/**"}}}
|
||||
st, err := Backup(profileA, store, "S-1-5-21-test", `DOMAIN\user`, folders, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Files != 1 {
|
||||
t.Fatalf("expected 1 file, got %+v", st)
|
||||
}
|
||||
st, found, err := Restore(profileB, store, "S-1-5-21-test", folders)
|
||||
if err != nil || !found || st.Files != 1 {
|
||||
t.Fatalf("restore: found=%v stats=%+v err=%v", found, st, err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(profileB, "AppData", "Roaming", "Example", "settings.json"))
|
||||
if err != nil || string(b) != "v1" {
|
||||
t.Fatalf("restored content = %q, err=%v", b, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(profileB, "AppData", "Roaming", "Example", "Cache", "skip.bin")); !os.IsNotExist(err) {
|
||||
t.Fatalf("excluded cache restored unexpectedly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanRelativeRejectsEscape(t *testing.T) {
|
||||
for _, v := range []string{"../x", `..\\x`, `/etc`, `C:\\Users\\x`, ""} {
|
||||
if _, err := cleanRelative(v); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, p, s string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreOverwritesSnapshotFilesButKeepsLocalExtras(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(root, "source")
|
||||
store := filepath.Join(root, "store")
|
||||
dest := filepath.Join(root, "dest")
|
||||
folders := []model.ProfileFolder{{Path: "Desktop"}}
|
||||
mustWrite(t, filepath.Join(source, "Desktop", "managed.txt"), "from-backup")
|
||||
if _, err := Backup(source, store, "S-1-5-21-merge", `DOMAIN\\user`, folders, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(dest, "Desktop", "managed.txt"), "local-old")
|
||||
mustWrite(t, filepath.Join(dest, "Desktop", "local-only.txt"), "keep-me")
|
||||
if _, found, err := Restore(dest, store, "S-1-5-21-merge", folders); err != nil || !found {
|
||||
t.Fatalf("restore found=%v err=%v", found, err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dest, "Desktop", "managed.txt"))
|
||||
if err != nil || string(b) != "from-backup" {
|
||||
t.Fatalf("managed content=%q err=%v", b, err)
|
||||
}
|
||||
b, err = os.ReadFile(filepath.Join(dest, "Desktop", "local-only.txt"))
|
||||
if err != nil || string(b) != "keep-me" {
|
||||
t.Fatalf("local-only content=%q err=%v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupHistoryRetention(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
profileRoot := filepath.Join(root, "profile")
|
||||
store := filepath.Join(root, "store")
|
||||
folders := []model.ProfileFolder{{Path: "Desktop"}}
|
||||
for i, content := range []string{"one", "two", "three", "four"} {
|
||||
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), content)
|
||||
if _, err := Backup(profileRoot, store, "S-1-5-21-history", `DOMAIN\\user`, folders, 2); err != nil {
|
||||
t.Fatalf("backup %d: %v", i, err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
history := filepath.Join(store, "S-1-5-21-history", "history")
|
||||
entries, err := os.ReadDir(history)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("history entries=%d, want 2", len(entries))
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-history", "current", "Desktop", "version.txt"))
|
||||
if err != nil || string(b) != "four" {
|
||||
t.Fatalf("current content=%q err=%v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupActivationGuardKeepsPreviousCurrent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
profileRoot := filepath.Join(root, "profile")
|
||||
store := filepath.Join(root, "store")
|
||||
folders := []model.ProfileFolder{{Path: "Desktop"}}
|
||||
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "old")
|
||||
if _, err := Backup(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWrite(t, filepath.Join(profileRoot, "Desktop", "version.txt"), "new")
|
||||
if _, err := BackupGuarded(profileRoot, store, "S-1-5-21-guard", `DOMAIN\\user`, folders, 1, func() error {
|
||||
return errors.New("user became active")
|
||||
}); err == nil {
|
||||
t.Fatal("expected activation guard error")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(store, "S-1-5-21-guard", "current", "Desktop", "version.txt"))
|
||||
if err != nil || string(b) != "old" {
|
||||
t.Fatalf("current snapshot changed despite guard: %q err=%v", b, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user