Skip to content

rustic: GetSnapshot() fails to parse grouped snapshot output, causing backup downloads to return HTTP 400 #29

Description

@xrenata

Bug Description

GetSnapshot() in src/server/backup/rustic_local.go attempts to unmarshal rustic snapshots --json output into a flat []SnapshotInfo, but rustic outputs a grouped format since recent versions. This causes the snapshot ID field to always be empty, which makes CanDownload() return false, and the download endpoint returns HTTP 400.

Root Cause

rustic snapshots --json [id] outputs grouped format:

[
  {
    "group_key": { ... },
    "snapshots": [
      { "id": "e38a66292dc4...", "time": "...", "paths": [...], ... }
    ]
  }
]

But GetSnapshot() tries to unmarshal into []SnapshotInfo (flat format):

var snapshots []SnapshotInfo
if err := json.Unmarshal(output, &snapshots); err != nil {
    return nil, errors.Wrap(err, "failed to parse snapshot info")
}

Because Go's json.Unmarshal silently ignores unknown fields, this does not return an error — it simply produces a slice where every field is the zero value. snapshots[0].ID is "", so CanDownload() returns false, and the download handler aborts with HTTP 400:

The requested backup cannot be downloaded.

The Inconsistency

ListSnapshots() in the same file correctly handles grouped format:

var results []struct {
    GroupKey  json.RawMessage `json:"group_key"`
    Snapshots []SnapshotInfo  `json:"snapshots"`
}
if err := json.Unmarshal(output, &results); err != nil {
    return nil, errors.Wrap(err, "failed to parse snapshot list")
}

GetSnapshot() should be updated to use the same approach.

Suggested Fix

In src/server/backup/rustic_local.go, change GetSnapshot() to handle grouped format, mirroring ListSnapshots():

func (r *LocalRepository) GetSnapshot(ctx context.Context, id string) (*Snapshot, error) {
    // ... existing code to run rustic ...

    // Handle grouped format (same as ListSnapshots)
    var grouped []struct {
        GroupKey  json.RawMessage `json:"group_key"`
        Snapshots []SnapshotInfo  `json:"snapshots"`
    }
    if err := json.Unmarshal(output, &grouped); err != nil {
        return nil, errors.Wrap(err, "failed to parse snapshot info")
    }
    var info SnapshotInfo
    found := false
    for _, group := range grouped {
        for _, s := range group.Snapshots {
            if strings.HasPrefix(s.ID, id) {
                info = s
                found = true
                break
            }
        }
        if found {
            break
        }
    }
    if !found {
        return nil, errors.New("snapshot not found")
    }
    // ... rest of existing code ...
}

Impact

  • All rustic backup downloads fail with HTTP 400 for users on recent rustic versions that output grouped snapshot format.
  • ListSnapshots() works correctly (backups appear in the panel list), so the issue is not immediately obvious — backups look fine until you try to download them.

Workaround

Wrap the rustic binary with a script that flattens grouped output using jq:

if [[ "$@" == *"snapshots"* && "$@" == *"--json"* ]]; then
    FLAT=$(echo "$OUTPUT" | jq 'if (type == "array") and (length > 0) and (.[0] | has("snapshots")) then [.[].snapshots[]] else . end')
    printf '%s' "$FLAT"
fi

Environment

  • Elytra v1.4.0
  • rustic (recent version outputting grouped snapshot JSON)
  • Panel: Pyrodactyl (Pterodactyl fork)
  • Disk type: rustic_local

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions