Skip to content
Open
38 changes: 38 additions & 0 deletions cmd/opensave-app/frontend/src/views/Devices.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@
run(() => api.del(`/api/peers/${peer.id}`), `Unpaired ${peer.name}`);
};

// A game a paired device syncs is tracked by itself only at a folder this
// device recognises as that game's save; anywhere else it waits on Home
// for the user to place. Their own devices they can let choose instead, as
// every device could before 2.4.1 — which is why turning it on asks.
const setChoosesFolders = async (peer, box) => {
const allowed = box.checked;
if (allowed) {
const ok = await askConfirm(
`Only do this for a device that is yours. When "${peer.name}" syncs a game this device ` +
`doesn't recognise, its saves go in the folder "${peer.name}" names, instead of waiting ` +
`on Home for you to choose one.`,
{ title: `Trust "${peer.name}" to choose folders?`, confirmText: 'Trust it' }
);
if (!ok) {
box.checked = false; // as it was: nothing changed
return;
}
}
run(
() => api.post(`/api/peers/${peer.id}/choose-folders`, { allowed }),
allowed ? `${peer.name} can choose folders here` : `${peer.name} no longer chooses folders here`
);
};

// The only repair for a pairing with no encryption key is to make it again,
// and this button can only do the first half of that. Saying "Pair again to
// encrypt" on a control that just unpairs would leave someone believing they
Expand Down Expand Up @@ -115,6 +139,15 @@
</button>
{/if}
</div>
<label class="check trust" title="For your own devices: games it syncs go in the folder it names, even one this device doesn't recognise as a save, instead of waiting on Home for you.">
<input
type="checkbox"
checked={!!peer.choosesFolders}
disabled={busy}
on:change={(e) => setChoosesFolders(peer, e.currentTarget)}
/>
Trust it to choose folders
</label>
</div>
{#if peer.hasNewerBuild && peer.status === 'online'}
<button class="btn small primary" disabled={busy || !!$appUpdate} on:click={() => updateFromPeer(peer)}>
Expand Down Expand Up @@ -226,6 +259,11 @@
margin-top: 0.35rem;
flex-wrap: wrap;
}
.trust {
margin-top: 0.45rem;
font-size: 0.82rem;
color: var(--text-dim);
}
.linkish {
background: none;
border: none;
Expand Down
11 changes: 6 additions & 5 deletions cmd/opensave-app/frontend/src/views/home/OfferedGames.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script>
// ── Games a peer syncs that this device has no folder for ─────────
//
// Only ever populated when "Ask me where to keep it" is chosen in Settings;
// with the default an unknown game is tracked automatically and never
// reaches this list. Shown at the top of the page on purpose: an offer
// nobody notices is worse than a folder guessed slightly wrong, because a
// wrong guess is at least visible and can be moved afterwards.
// A peer's game lands here when this device cannot vouch for the folder as
// that game's save folder (CVE-2026-103398), and every unknown game does
// with "Ask me where to keep it" in Settings. Shown at the top of the page
// on purpose: an offer nobody notices is worse than a folder guessed
// slightly wrong, because a wrong guess is at least visible and can be
// moved afterwards.
import { gameList, toast } from '../../lib/stores.js';
import { withUndo, hiddenKeys } from '../../lib/undo.js';
import { api, native } from '../../lib/api.js';
Expand Down
120 changes: 120 additions & 0 deletions e2e/folder_choice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package e2e

import (
"net/http"
"os"
"path/filepath"
"testing"
"time"

"github.com/opensave/opensave/testutil"
)

// 2.4.2: a game arriving from a peer is tracked by itself wherever this device
// can vouch for the folder — including one that does not exist yet — and the
// user decides the rest (CVE-2026-103398).

// A new device fills itself in: a game in the save catalogue arrives before it
// was ever played here, and is tracked where the catalogue says it saves on
// this device, its saves in place before first launch.
func TestFolderChoice_ACatalogueGameArrivesBeforeItsFolderExists(t *testing.T) {
a := testutil.NewTestDaemon(t, "Catalogue-A")
b := testutil.NewTestDaemon(t, "Catalogue-B")
a.PairWith(b)

// Each device's own application-data folder, as the catalogue resolves
// it; the path rule maps one to the other, as two users' profiles map.
rootA, rootB := t.TempDir(), t.TempDir()
t.Setenv("APPDATA", filepath.Join(rootB, "AppData", "Roaming"))
b.API(http.MethodPost, "/api/settings", map[string]any{
"pathTranslations": []map[string]string{{"fromPattern": rootA, "toPattern": rootB}},
}, nil)
manifest := `
Hollow Game:
files:
<winAppData>/MoonStudio/HollowGame:
tags: [save]
when:
- os: windows
`
if err := os.WriteFile(filepath.Join(filepath.Dir(b.Daemon.Scanner.CacheFile), "ludusavi-manifest.yaml"), []byte(manifest), 0o666); err != nil {
t.Fatal(err)
}
b.Daemon.Scanner.GOOS = "windows"

saveA := filepath.Join(rootA, "AppData", "Roaming", "MoonStudio", "HollowGame")
writeAt(t, filepath.Join(saveA, "slot0.dat"), "act two")
var tracked gameRow
a.API(http.MethodPost, "/api/games", map[string]string{"name": "Hollow Game", "savePath": saveA}, &tracked)
a.API(http.MethodPost, "/api/games/"+tracked.ID+"/sync", nil, nil)

want := filepath.Join(rootB, "AppData", "Roaming", "MoonStudio", "HollowGame")
if !testutil.WaitFor(45*time.Second, func() bool {
return readAt(filepath.Join(want, "slot0.dat")) == "act two"
}) {
offers, _ := b.Daemon.Store.ListOfferedGames()
t.Fatalf("the save never arrived in the game's folder on B; B has %+v, offers %+v", gamesOf(b), offers)
}
if offers, _ := b.Daemon.Store.ListOfferedGames(); len(offers) != 0 {
t.Errorf("a game this device could vouch for was offered too: %+v", offers)
}
}

// A device the user trusts to choose folders has its hand-picked folder
// synced by itself; the same game from a device not trusted is offered.
func TestFolderChoice_ATrustedDevicesOwnFolderSyncs(t *testing.T) {
a := testutil.NewTestDaemon(t, "Trusted-A")
b := testutil.NewTestDaemon(t, "Trusted-B")
a.PairWith(b)

var ok map[string]any
b.API(http.MethodPost, "/api/peers/"+a.NodeID()+"/choose-folders", map[string]any{"allowed": true}, &ok)
if ok["choosesFolders"] != true {
t.Fatalf("could not trust A: %+v", ok)
}

a.WriteSave("homebrew.sav", "hand-picked")
gameID := a.TrackGame("Homebrew Game")
syncTo(a, gameID, b.NodeID())

if !testutil.WaitFor(30*time.Second, func() bool {
_, err := b.Daemon.Store.GetGame(gameID)
return err == nil
}) {
offers, _ := b.Daemon.Store.ListOfferedGames()
t.Fatalf("the trusted device's game was not tracked on B; offers %+v", offers)
}
if offers, _ := b.Daemon.Store.ListOfferedGames(); len(offers) != 0 {
t.Errorf("tracked, yet still offered: %+v", offers)
}
}

// An offer answers itself: once this device knows the folder, the peer's
// next sync tracks the game and the offer is gone from Home.
func TestFolderChoice_AnOfferClearsWhenTheFolderBecomesKnown(t *testing.T) {
a := testutil.NewTestDaemon(t, "Clears-A")
b := testutil.NewTestDaemon(t, "Clears-B")
a.PairWith(b)

a.WriteSave("slot1.sav", "from A")
gameID := a.TrackGame("Later Game")
syncTo(a, gameID, b.NodeID())
if !testutil.WaitFor(30*time.Second, func() bool {
offers, _ := b.Daemon.Store.ListOfferedGames()
return len(offers) == 1
}) {
t.Fatal("the game was not offered on B first")
}

b.KnowPeersSaveFolder(a.SaveDir) // B's scan finds the folder
syncTo(a, gameID, b.NodeID())

if !testutil.WaitFor(30*time.Second, func() bool {
_, err := b.Daemon.Store.GetGame(gameID)
offers, _ := b.Daemon.Store.ListOfferedGames()
return err == nil && len(offers) == 0
}) {
offers, _ := b.Daemon.Store.ListOfferedGames()
t.Fatalf("not tracked, or the offer stayed: B has %+v, offers %+v", gamesOf(b), offers)
}
}
7 changes: 4 additions & 3 deletions internal/api/routes_offered_games.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (

// Games a peer syncs that this device has not been given a folder for.
//
// These exist only when the device is set to ask before tracking; with the
// default setting an unknown game is tracked at a guessed folder and never
// becomes an offer. See internal/store/migrations/0021_offered_games.sql.
// A game becomes an offer when this device cannot vouch for the peer's folder
// as that game's save folder (CVE-2026-103398; p2p ensureManifestGame), and
// every unknown game does when the device is set to ask before tracking. See
// internal/store/migrations/0021_offered_games.sql.

func (s *Server) handleListOfferedGames(w http.ResponseWriter, r *http.Request) {
offers, err := s.Daemon.Store.ListOfferedGames()
Expand Down
30 changes: 30 additions & 0 deletions internal/api/routes_peers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
Expand All @@ -25,6 +26,7 @@ func (s *Server) peerRoutes(r chi.Router) {
r.Delete("/api/peers/{peerId}", s.handleDeletePeer)
r.Post("/api/peers/probe", s.handleProbePeer)
r.Get("/api/peers/{peerId}/games", s.handlePeerGames)
r.Post("/api/peers/{peerId}/choose-folders", s.handlePeerChoosesFolders)

r.Post("/api/games/sync-all", s.handleSyncAll)
r.Post("/api/games/{gameId}/sync", s.handleSyncGame)
Expand Down Expand Up @@ -359,3 +361,31 @@ func itoa(n int) string {
}
return string(b[i:])
}

// handlePeerChoosesFolders lets a paired device choose the folders of games it
// syncs to this one, or stops it: {"allowed": true|false}. Off, a game from
// that device is tracked by itself only at a folder this one can vouch for as
// its save, and offered otherwise (CVE-2026-103398).
func (s *Server) handlePeerChoosesFolders(w http.ResponseWriter, r *http.Request) {
peerID := chi.URLParam(r, "peerId")
var body struct {
Allowed *bool `json:"allowed"`
}
if err := readJSON(r, &body); err != nil || body.Allowed == nil {
writeError(w, http.StatusBadRequest, `send {"allowed": true} or {"allowed": false}`)
return
}
if err := s.Daemon.Store.SetPeerChoosesFolders(peerID, *body.Allowed); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if peer, err := s.Daemon.Store.GetPeer(peerID); err == nil {
if *body.Allowed {
s.Daemon.Log.Log("info", fmt.Sprintf("%q may now choose the folders of games it syncs here", peer.Name))
} else {
s.Daemon.Log.Log("info", fmt.Sprintf("%q no longer chooses folders here: games it syncs are tracked only at known save folders", peer.Name))
}
}
s.BroadcastPeersUpdate()
writeJSON(w, http.StatusOK, map[string]any{"success": true, "choosesFolders": *body.Allowed})
}
5 changes: 5 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ func (s *Server) peersPayload() map[string]any {
// "update from this device" when a peer runs a newer build.
entry := struct {
store.Peer
// Whether this device may choose the folders of games it syncs
// here (Devices; migration 0044).
ChoosesFolders bool `json:"choosesFolders"`

AppVersion string `json:"appVersion,omitempty"`
BuildTimeMs int64 `json:"buildTimeMs,omitempty"`
HasNewerBuild bool `json:"hasNewerBuild,omitempty"`
Expand All @@ -112,6 +116,7 @@ func (s *Server) peersPayload() map[string]any {
entry.BuildTimeMs = b.BuildTimeMs
entry.HasNewerBuild = version.NewerThanLocal(b.AppVersion, b.BuildTimeMs)
}
entry.ChoosesFolders = s.Daemon.Store.PeerChoosesFolders(p.ID)
peerMap[p.ID] = entry
}
discovered := []any{}
Expand Down
34 changes: 34 additions & 0 deletions internal/cliapp/commands_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,9 @@ func cmdPeers(args []string) int {
if len(rest) > 0 && rest[0] == "games" {
return cmdPeerGames(args[1:])
}
if len(rest) > 0 && rest[0] == "trust" {
return cmdPeerTrust(args[1:])
}

raw, err := daemonRequest("GET", "/api/peers", nil)
if err != nil {
Expand Down Expand Up @@ -1067,3 +1070,34 @@ const relayUsage = `usage:
opensave relay status Show the current relay room
opensave relay join <code> Join a relay room (same code on every device)
opensave relay leave Leave the current room`

// cmdPeerTrust lets a paired device choose the folders of games it syncs to
// this one, or stops it — the Devices page's "Trust it to choose folders".
// Off, such a game is tracked by itself only at a folder this device
// recognises as its save, and waits on Home otherwise.
func cmdPeerTrust(args []string) int {
asJSON, args := jsonFlag(args)
if len(args) != 2 || (args[1] != "on" && args[1] != "off") {
fmt.Fprintln(os.Stderr,
"usage: opensave peers trust <peerId> on|off\n"+
" on: games that device syncs here go in the folder it names, even one\n"+
" this device doesn't recognise as a save. Only for your own devices.\n"+
" off: such games wait for you to choose a folder (`opensave offers`).\n"+
" Device ids come from `opensave peers`.")
return 1
}
allowed := args[1] == "on"
raw, err := daemonRequest("POST", "/api/peers/"+url.PathEscape(args[0])+"/choose-folders", map[string]any{"allowed": allowed})
if err != nil {
return fail(asJSON, err)
}
if asJSON {
return emitRawJSON(raw)
}
if allowed {
fmt.Println(okText(sym("✓", "ok")) + " that device now chooses the folders of games it syncs here")
} else {
fmt.Println(okText(sym("✓", "ok")) + " that device no longer chooses folders here")
}
return 0
}
1 change: 1 addition & 0 deletions internal/cliapp/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ var topLevelCommands = commandNames()

var subCommands = map[string][]string{
"pair": {"requests", "approve", "reject"},
"peers": {"games", "trust"},
"relay": {"status", "join", "leave"},
"resolve": {"keep-both", "keep-local", "keep-remote"},
"daemon": {"start", "status", "stop"},
Expand Down
1 change: 1 addition & 0 deletions internal/cliapp/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ var commandGroups = []commandGroup{
{"transfers", "What is moving between devices, and what moved lately"},
{"peers", "Paired, discovered and pending devices"},
{"peers games <peerId>", "What that device is tracking"},
{"peers trust <peerId> on|off", "Let your own device choose folders here"},
{"pair <host[:port]>", "Ask a device on your LAN to pair"},
{"pair <node id>", "Ask a device in your relay room to pair"},
{"pair requests|approve|reject", "Handle incoming pairing requests"},
Expand Down
21 changes: 15 additions & 6 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ type Daemon struct {
incomplete incompleteAutoSnapshots
// scanMu runs one save scan at a time. See ScanForSaves.
scanMu sync.Mutex
// A scan for a peer's offered game: one at a time, at most once a
// minute (scanForOffer).
offerScanMu sync.Mutex
offerScanRunning bool
lastOfferScan time.Time

// uploads counts cloud mirrors still running, so Stop can wait for them
// rather than letting process exit truncate one.
Expand Down Expand Up @@ -200,15 +205,19 @@ func New(opts Options) (*Daemon, error) {
d.P2P.OnAutoTracked = d.adoptAutoTracked
// A Switch save a peer syncs goes into this device's own emulator profile.
d.P2P.SwitchSaveFolder = d.Scanner.SwitchSaveFolder
// What a game arriving from a peer may be tracked at without asking:
// a folder this device's own scanner noted as a save, one inside an
// emulator's save folder here, or a Switch title's slot in a NAND here.
// Anything else is offered to the user.
d.P2P.KnownSaveLocation = func(path string) bool {
// What a game arriving from a peer may be tracked at without asking: a
// folder this device's own scanner noted as a save, one inside an
// emulator's save folder here, a Switch title's slot in a NAND here, or
// where the save catalogue or Steam says that game keeps its saves here,
// before it exists. Anything else is offered to the user.
d.P2P.OnGameOffered = d.scanForOffer
d.P2P.KnownSaveLocation = func(name, appID, path string) bool {
if known, err := d.Store.IsKnownSave(path); err == nil && known {
return true
}
return d.Scanner.InsideEmulatorSaveRoot(path) || presets.IsSwitchSaveSlot(path)
return d.Scanner.InsideEmulatorSaveRoot(path) || presets.IsSwitchSaveSlot(path) ||
d.Scanner.CatalogueSaveLocation(name, appID, path) ||
d.Scanner.SteamSaveLocation(appID, path)
}

// Every new snapshot mirrors to the configured cloud provider in the
Expand Down
Loading
Loading