Skip to content

Conversation

@Shellywell123
Copy link

@Shellywell123 Shellywell123 commented Nov 9, 2025

A new cloud save method that will mirror the structure of the users game library.

The new Mirror Game Library Structure option under cloud save configurations, will mimic the structure of the users game library. This is an attempt to improve the current Emulator/Game ID based system, where the IDs are dependant on the order you setup emulators and import games. The advantage of this new method is that the mapping of saves to games persists even if the database is destroyed.

Web Frontend

New drop down allowing the user to specify the file save path structure.
Screenshot from 2025-11-07 20-02-30

Server File structure

example game lib

> tree app/example-library/
app/example-library/
└── gameboy
    ├── pokemon
    │   └── pokemon.gb
    ├── tetris
    │   └── Tetris.gb
    └── zelda
        └── zelda.gb

5 directories, 3 files

default Emulator/Game ID basedmethod

> tree ~/.local/share/server/saves/
/home/shellywell123/.local/share/server/saves/
└── 1
    ├── 2
    │   └── data
    │       └── saves
    │           └── mGBA
    │               └── 3.srm
    └── 6
        └── data
            └── saves
                └── mGBA
                    └── 5.srm

10 directories, 2 files

new Mirror Game Library Structure method

> tree ~/.local/share/server/saves/
/home/shellywell123/.local/share/server/saves/
└── gameboy
    ├── pokemon
    │   └── data
    │       └── saves
    │           └── mGBA
    │               └── 2.srm
    └── zelda
        └── data
            └── saves
                └── mGBA
                    └── 1.srm

10 directories, 2 files

Disclaimer: This was built heavily with AI using sonnet 4.5 and copilot

@Shellywell123 Shellywell123 changed the title A new cloud save method that will mirror the structure of the users g… feat(cloud save config): mirror game lib structure Nov 9, 2025
@JMBeresford JMBeresford requested a review from Copilot November 9, 2025 19:07
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This pull request adds a configurable save directory structure feature, allowing users to choose between emulator/game ID-based paths or a structure that mirrors the game library organization. The implementation spans protobuf schema changes, backend Rust services, and frontend TypeScript UI components.

Key changes:

  • Introduced a new SaveDirStructure enum with two options: EMULATOR_GAME (default) and MIRROR_LIBRARY
  • Modified save file and save state managers to support dynamic path resolution based on configuration
  • Added UI controls for users to select their preferred directory structure

Reviewed Changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
packages/codegen/protos/retrom/server/config.proto Added SaveDirStructure enum and save_dir_structure field to SavesConfig message
packages/codegen/protos/retrom/services/server-config.proto Added deprecation notice that file was moved
packages/service-common/src/config/mod.rs Set default save_dir_structure configuration to EmulatorGame
packages/grpc-service/src/saves/save_file_manager.rs Implemented mirror library path structure logic for save files with async config access
packages/grpc-service/src/saves/save_state_manager.rs Implemented mirror library path structure logic for save states with async config access
packages/grpc-service/src/saves/mod.rs Added .await calls for async directory path resolution methods
packages/client-web/src/components/modals/config/server/saves-config.tsx Added UI dropdown for selecting save directory structure preference
packages/client-web/src/queries/saveFiles.tsx Added optional enabled parameter to useGetSaveFiles query hook
packages/client-web/src/providers/emulator-js/ejs-session.tsx Added fallback for undefined game.id when constructing save file query
packages/client-web/vite.config.ts Added error handler to silently ignore telemetry connection refused errors
packages/client/gen/schemas/linux-schema.json Auto-generated schema updates for Tauri permissions and capabilities

Comment on lines +290 to +294
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
tracing::info!("Final save path: {:?}", save_path);
return Ok(saves_dir.join(save_path));
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = saves_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&saves_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +293
tracing::info!(
"get_saves_dir - Game ID: {}, Save dir structure config: {:?}, MirrorLibrary enum value: {}, comparison: {}",
self.game.id,
save_dir_structure,
mirror_library_value,
save_dir_structure.map(|s| s == mirror_library_value).unwrap_or(false)
);

if save_dir_structure.map(|s| s == mirror_library_value).unwrap_or(false) {
let game_path = PathBuf::from(&self.game.path);
tracing::info!("Mirror library mode - Game path: {:?}", game_path);

// Find the content directory that contains this game
let content_dirs = &config.content_directories;
tracing::info!("Content directories: {:?}", content_dirs);
for content_dir in content_dirs {
let content_path = PathBuf::from(&content_dir.path);
tracing::info!("Checking content path: {:?}", content_path);

// Try to get the relative path from the content directory to the game
if let Ok(relative_game_path) = game_path.strip_prefix(&content_path) {
tracing::info!("Found matching content dir! Relative game path: {:?}", relative_game_path);

// Extract platform and game name from the relative path
let path_components: Vec<&str> = relative_game_path.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();

tracing::info!("Path components: {:?}", path_components);

// For both single-file and multi-file games, we want platform/game_name structure
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];

tracing::info!("Using platform: {:?}, game_name: {:?}", platform, game_name);

// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
tracing::info!("Final save path: {:?}", save_path);
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excessive debug logging should be removed before merging to production. Lines 246-293 contain numerous tracing::info! calls that log detailed internal state including path components, configuration values, and directory structures. This adds noise to logs and could expose sensitive information about the file system structure.

Consider either:

  • Removing these debug logs entirely, or
  • Changing them to tracing::debug! level so they're only visible when explicitly enabled for debugging

Copilot uses AI. Check for mistakes.
Comment on lines +356 to +359
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(backup_dir.join(save_path));
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = backup_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&backup_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
.join(emulator_id.to_string())
.join(self.game.id.to_string()))
// Check if we should use the game library path structure
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The complex conditional logic on line 251 is difficult to read and understand. The nested as_ref(), and_then(), map(), and unwrap_or() chain with an embedded enum comparison makes it hard to follow the logic flow.

Consider extracting this to a helper method or variable:

let should_use_mirror_library = config.saves
    .as_ref()
    .and_then(|s| s.save_dir_structure)
    .map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
    .unwrap_or(false);

if should_use_mirror_library {
    // ...
}

This improves readability and makes the condition's purpose clearer.

Suggested change
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
let should_use_mirror_library = config.saves
.as_ref()
.and_then(|s| s.save_dir_structure)
.map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
.unwrap_or(false);
if should_use_mirror_library {

Copilot uses AI. Check for mistakes.
.join(emulator_id.to_string())
.join(self.game.id.to_string()))
// Check if we should use the game library path structure
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The complex conditional logic on line 317 is difficult to read and understand. The nested as_ref(), and_then(), map(), and unwrap_or() chain with an embedded enum comparison makes it hard to follow the logic flow.

Consider extracting this to a helper method or variable:

let should_use_mirror_library = config.saves
    .as_ref()
    .and_then(|s| s.save_dir_structure)
    .map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
    .unwrap_or(false);

if should_use_mirror_library {
    // ...
}

This improves readability and makes the condition's purpose clearer.

Suggested change
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
let should_use_mirror_library = config.saves
.as_ref()
.and_then(|s| s.save_dir_structure)
.map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
.unwrap_or(false);
if should_use_mirror_library {

Copilot uses AI. Check for mistakes.
Comment on lines +279 to +282
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(backup_dir.join(save_path));
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = backup_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&backup_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
Comment on lines +328 to +349
if let Some(game_dir) = relative_game_path.parent() {
let path_components: Vec<&str> = game_dir.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();

// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];

// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));
}
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent path extraction logic between save file and save state managers. In save_file_manager.rs, the path components are extracted directly from relative_game_path (line 270), while in save_state_manager.rs, they're extracted from relative_game_path.parent().

This means:

  • Save files: Uses path structure like platform/game_name from the full game file path
  • Save states: Uses path structure like platform/game_name from the parent directory of the game file path

This could lead to different directory structures for save files vs save states for the same game, which is likely unintended. Consider standardizing the path extraction logic across both managers to ensure consistency.

Suggested change
if let Some(game_dir) = relative_game_path.parent() {
let path_components: Vec<&str> = game_dir.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();
// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];
// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));
}
// Extract path components directly from relative_game_path (not its parent)
let path_components: Vec<&str> = relative_game_path.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();
// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];
// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 53
const saveFilesQuery = useGetSaveFiles({
saveFilesSelectors: [{ gameId: game.id, emulatorId: emulator?.id }],
saveFilesSelectors: [
{
gameId: game?.id ?? 0,
emulatorId: emulator?.id,
},
],
});
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue with the query being enabled even when game ID is 0. The fallback game?.id ?? 0 on line 49 means the query will execute with gameId: 0 when game is undefined. This could result in unnecessary API calls with invalid data.

Consider either:

  1. Disabling the query when game is not available using the new enabled option:
const saveFilesQuery = useGetSaveFiles(
  {
    saveFilesSelectors: [{
      gameId: game?.id ?? 0,
      emulatorId: emulator?.id,
    }],
  },
  { enabled: !!game?.id }
);
  1. Or conditionally call the hook only when game is available.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +348
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));
Copy link

Copilot AI Nov 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = states_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&states_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@JMBeresford
Copy link
Owner

@Shellywell123 Ignore the copilot review for now -- I'll go over its comments and let you know if any are relevant once I have time to review everything myself. Thanks! 🙂

Shellywell123 and others added 2 commits November 11, 2025 16:59
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Shellywell123 <44142139+Shellywell123@users.noreply.github.com>
@JMBeresford
Copy link
Owner

@Shellywell123 There are a couple concerns I have with this approach:

  1. Not all emulators for a given system will handle save files in the same way -- this is why saves are currently stored in the way they are. Opting out of an emulator identifier in favor of just the system it emulates means this structure will not be portable across all use cases. In other words, this will break for some sub-set of users in the future. Ultimately, saves must be managed on a per-emulator basis.
  2. Moving away from numerical IDs to name-based folders for this structure leads to quite a bit of fragility as a consequence. When a user renames a library entry, for example, these saves would now also need to be carefully considered and renamed. This would need to all be done atomically, so that an error in renaming one thing does not leave orphaned saves.

Is the goal of this change solely to guard against a loss or failure of the Retrom DB? If so, I would highly prefer to dedicate time and effort into a proper backup+restore solution with the DB in mind.

What are your thoughts?

@Shellywell123
Copy link
Author

Shellywell123 commented Nov 24, 2025

@JMBeresford To address your concerns + ask some clarifying questions back. :)

  1. "Not all emulators for a given system will handle save files in the same way"
    I totally agree, and as a User I also want my saves to be on a "per emulator basis" and believe my solution does also solve for this. The new file paths of the saves would still include the emulator name.
    As stated in the description the new file path would become:
    /home/shellywell123/.local/share/server/saves/gameboy/pokemon/data/saves/mGBA/2.srm
    where mGBA is the emulator.
    Maybe I am missing some context here, but why do we need an emulator id in the path at all? Is just having the emulator name not enough?

  2. "numerical IDs to name-based folders for this structure leads to quite a bit of fragility"
    I once again agree. That a messy/modified library will lead to issues.
    What are your thoughts on including it with a warning or alternatively exploring the use of IGDB metadata, to keep things uniform?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants