Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
29acea4
test(project): require no-clobber staged publication
seonghobae Aug 20, 2026
e673356
test(project): wire no-clobber publication regression
seonghobae Aug 20, 2026
f476181
fix(project): stage and publish new saves without clobber
seonghobae Aug 20, 2026
18d5812
test(project): require safe save publication wiring
seonghobae Aug 20, 2026
200eac3
fix(project): publish saves without clobbering existing files
seonghobae Aug 20, 2026
1ecb7c4
test(project): require bounded project reads
seonghobae Aug 20, 2026
f852054
fix(project): bound project reads at the file boundary
seonghobae Aug 20, 2026
eae423c
fix(project): route load through bounded reader
seonghobae Aug 20, 2026
f99d41f
docs(changelog): record bounded project persistence
seonghobae Aug 20, 2026
935bfa8
test(project): reject symlink project reads
seonghobae Aug 21, 2026
2076715
fix(project): reject directly selected symlink loads
seonghobae Aug 21, 2026
d38862c
docs(changelog): record symlink-safe project loading
seonghobae Aug 21, 2026
ad1d791
test(project): prove load TOCTOU path swap
seonghobae Aug 21, 2026
6cc163c
fix(project): bind load preflight to opened file
seonghobae Aug 21, 2026
aec9c4e
docs(changelog): record project load identity guard
seonghobae Aug 21, 2026
33da8db
test(project): reject symlinked save parent
seonghobae Aug 21, 2026
3bc0114
fix(project): reject symlinked save parent
seonghobae Aug 21, 2026
f08dd97
docs(project): record save-parent trust boundary
seonghobae Aug 21, 2026
39272d2
test(project): reject symlinked save ancestors
seonghobae Aug 21, 2026
2f6afc9
fix(project): validate save parent chain
seonghobae Aug 21, 2026
49c002c
test(project): keep portable parent-link boundary
seonghobae Aug 21, 2026
2e48e59
fix(project): preserve portable parent validation
seonghobae Aug 21, 2026
0c991df
docs(changelog): preserve protected release history
seonghobae Aug 26, 2026
5caf248
merge: integrate protected develop into project persistence lane
seonghobae Aug 26, 2026
bb4a827
test(project): reproduce symlink swap at handle acquisition
seonghobae Aug 28, 2026
bae6de7
fix(project): refuse final symlink at Unix open boundary
seonghobae Aug 28, 2026
d86bc28
test(project): expose crate-local opener to regression harness
seonghobae Aug 28, 2026
5197f4f
test(project): exercise Unix opener without source inclusion
seonghobae Aug 28, 2026
53280f8
docs(project): record no-follow load acquisition
seonghobae Aug 28, 2026
d70a252
test(project): reproduce confirmed overwrite regression
seonghobae Aug 28, 2026
f27ccfa
fix(project): stage before replacing confirmed saves
seonghobae Aug 28, 2026
2578579
test(project): reproduce hard-link-only save regression
seonghobae Aug 28, 2026
0f3d2de
fix(project): fall back when hard links are unavailable
seonghobae Aug 28, 2026
987ca7b
fix(project): report oversized save input
seonghobae Aug 28, 2026
0bab0e8
fix(project): keep staging names within filesystem limits
seonghobae Aug 28, 2026
7f2a846
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 28, 2026
54d8966
fix(project): fail closed without atomic first-save support
seonghobae Aug 28, 2026
eddbb02
test(project): cover hard-link-free atomic publication
seonghobae Aug 29, 2026
73af787
test(project): protect fallback reservation race
seonghobae Aug 29, 2026
1f658a6
fix(project): publish safely without hard links
seonghobae Aug 29, 2026
106da93
test(project): require native Windows file identity
seonghobae Aug 29, 2026
723b34b
fix(project): bind Windows reads to native file identity
seonghobae Aug 29, 2026
abb39bf
docs(project): describe portable native-safe persistence
seonghobae Aug 29, 2026
4eddf73
test(project): reproduce final-path reservation crash window
seonghobae Aug 29, 2026
16c68ad
fix(project): publish first saves with native no-replace rename
seonghobae Aug 29, 2026
ed913cc
docs(project): describe native no-replace publication
seonghobae Aug 29, 2026
6edb558
test(project): reproduce linked ancestor save redirect
seonghobae Aug 29, 2026
ba1ce2b
fix(project): reject linked save ancestors
seonghobae Aug 29, 2026
1969789
fix(project): allow trusted macOS root aliases
seonghobae Aug 29, 2026
b28a4b8
test(project): constrain macOS root alias authority
seonghobae Aug 29, 2026
5095e53
fix(project): constrain macOS root aliases
seonghobae Aug 29, 2026
f1b486d
test(project): reject existing-target identity swaps
seonghobae Aug 29, 2026
3d7b0a4
fix(project): reject stale existing-target replacement
seonghobae Aug 29, 2026
2880df6
docs(changelog): record conditional project replacement
seonghobae Aug 29, 2026
b9a1a2f
docs(changelog): preserve historical entries
seonghobae Aug 29, 2026
2ad4829
test(project): preserve restrictive overwrite permissions
seonghobae Aug 29, 2026
d5feefe
fix(project): preserve overwrite permissions
seonghobae Aug 29, 2026
9786629
test(project): reproduce Windows stage leak on failed replace
seonghobae Aug 29, 2026
702396b
fix(project): clean failed Windows replacement stages
seonghobae Aug 29, 2026
578f94b
fix(project): recover interrupted publications
seonghobae Aug 29, 2026
80bbf5f
fix(project): satisfy persistence lint
seonghobae Aug 29, 2026
d6a070d
fix(project): make recovery cleanup durable
seonghobae Aug 29, 2026
3006cd6
fix(project): recover raced publication rollback
seonghobae Aug 29, 2026
070f4cd
test(project): reproduce linked-folder recovery rejection
seonghobae Aug 29, 2026
1883196
fix(project): allow safe linked-folder reads without recovery state
seonghobae Aug 29, 2026
6fbedb8
test(project): reproduce stale recovery and stage leaks
seonghobae Aug 29, 2026
817cb56
fix(project): retire stale recovery state safely
seonghobae Aug 29, 2026
86b973f
test(project): strengthen atomic publication guard
seonghobae Aug 29, 2026
b2edbe9
test(project): cover Windows recovery cleanup
seonghobae Aug 29, 2026
00c7792
ci(project): run recovery cleanup on Windows
seonghobae Aug 29, 2026
1da4a1d
ci(project): satisfy Tauri context for Windows test
seonghobae Aug 29, 2026
dbee8b9
ci(project): set checkout default branch environment
seonghobae Aug 29, 2026
d9adf11
fix(project): harden recovery identity and file modes
seonghobae Aug 29, 2026
9b7e3e9
fix(project): recover after interrupted rollback
seonghobae Aug 29, 2026
64820be
feat(project): add versioned project file envelope
seonghobae Aug 29, 2026
d3337cf
fix(project): validate version and tempo before decode
seonghobae Aug 29, 2026
14fa46c
feat(project): add versioned project file envelope
seonghobae Aug 30, 2026
38fb6d0
test(project): reproduce first-save directory durability gap
seonghobae Sep 3, 2026
46f28ee
fix(project): require first-save directory durability
seonghobae Sep 3, 2026
fef65c2
fix(ci): pin project persistence Windows Rust toolchain
seonghobae Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/project-persistence-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: project-persistence-windows

on:
pull_request:
branches:
- develop
- main
paths:
- "apps/desktop/src-tauri/src/project_persistence.rs"
- "apps/desktop/src-tauri/tests/project_persistence*.rs"
- ".github/workflows/project-persistence-windows.yml"
push:
branches:
- develop
- main
paths:
- "apps/desktop/src-tauri/src/project_persistence.rs"
- "apps/desktop/src-tauri/tests/project_persistence*.rs"
- ".github/workflows/project-persistence-windows.yml"

permissions:
contents: read

env:
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: init.defaultBranch
GIT_CONFIG_VALUE_0: develop

jobs:
windows-recovery-cleanup:
name: test / project-persistence / windows
runs-on: windows-2025
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Rust 1.97.1
run: rustup toolchain install 1.97.1 --profile minimal
- name: Prepare compile-only frontendDist fixture
shell: pwsh
run: |
New-Item -ItemType Directory -Force apps/desktop/dist | Out-Null
Set-Content -Path apps/desktop/dist/index.html -Value '<!doctype html><title>BandScope test fixture</title>' -NoNewline
- name: Run Windows recovery-cleanup regression
run: cargo +1.97.1 test --manifest-path apps/desktop/src-tauri/Cargo.toml --no-default-features --tests
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Last updated: 2026-03-11
- Shared contracts live in `packages/shared-types` so the UI can evolve without importing Python internals.
- Shared contracts should ultimately model section, role, cue, confidence, and export artifacts explicitly enough that desktop UI and analysis outputs do not invent their own parallel schemas.
- The current shared-types baseline includes a rehearsal-domain fixture that exercises section, role, cue, confidence, provenance, and export-summary fields in the desktop shell before the full analysis pipeline lands.
- Project writes currently use an independent v1 JSON envelope around the validated rehearsal song; legacy raw song files remain readable, unknown envelope fields fail closed, and unsupported versions return an explicit error. Typed source, derived, decision, handoff, preference, and volatile runtime sections remain follow-up work under #962.
- Local analysis orchestration uses typed Tauri IPC commands and a Python subprocess over stdin/stdout rather than a loopback HTTP listener.
- Local audio intake bootstraps a project by validating a user-selected file in Rust, creating app-owned temp/cache/project roots, and referencing the original source file rather than copying it in this phase.
- Those bootstrap roots should resolve from app-owned Tauri data/cache paths instead of the shared system temp namespace.
Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section.
- Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace.
- 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함.
- Write project files through the versioned `projectFormatVersion: 1` envelope and retain validated tempo values across save/load, with explicit legacy and unsupported-version handling.

### Changed

Expand All @@ -15,6 +16,14 @@
### Fixed

- Upgraded the local score PDF parser to `pdfjs-dist` 6.2.108, pinned Undici 7.29.0 across the workspace, and constrained PDF loading to copied in-memory bytes with a same-origin bundled worker and npm-generated lock provenance.
- Stage and sync new project saves before non-clobbering publication, and enforce the existing 5 MiB project limit during the file read itself so a selected project cannot grow past a metadata preflight into an unbounded load allocation.
- Reject directly selected project symlinks before reading so a chosen `.bscope` path cannot silently redirect the loader to different file content.
- Reject a symlinked/reparse-point save parent before staging so a selected project path cannot redirect new project publication into a different directory.
- Fail closed when a selected `.bscope` path changes file identity between preflight and handle acquisition; Windows opens reparse points without following them and compares native volume serial plus file-index identity across the acquisition boundary.
- Refuse last-component symlink following during Linux/macOS project handle acquisition and make that acquisition non-blocking so a preflight-to-open path swap cannot redirect the loader or stall it on a special file.
- Preserve first-save crash safety on filesystems without hard-link support by publishing the fully synced staging file with an OS-native atomic no-replace rename, so a crash cannot leave an empty reserved final path.
- Reject a stale existing-project replacement when the selected target changes file identity while replacement bytes are staged; native exchange/backup publication restores the competing target instead of clobbering it.
- Recover an interrupted existing-project replacement from a bounded, same-directory identity journal when the target is selected again, while leaving mismatched files untouched.

## [0.1.3] - 2026-04-29

Expand Down Expand Up @@ -74,4 +83,4 @@

- `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다.
- `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다.
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`).
154 changes: 151 additions & 3 deletions apps/desktop/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,49 @@ pub enum AnalysisCacheStatus {
pub struct RehearsalSongPayload {
id: String,
title: String,
#[serde(
default,
deserialize_with = "deserialize_project_tempo",
skip_serializing_if = "Option::is_none"
)]
tempo: Option<f64>,
sections: Vec<RehearsalSectionPayload>,
export_summary: ExportSummaryPayload,
#[serde(default, skip_serializing_if = "Option::is_none")]
score_attachments: Option<Vec<ScoreAttachmentMetadataPayload>>,
}

fn deserialize_project_tempo<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::Number(number) => match number.as_f64() {
Some(tempo) if tempo.is_finite() && tempo > 0.0 => Ok(Some(tempo)),
_ => Err(serde::de::Error::custom(
"project tempo must be a finite positive number",
)),
},
_ => Err(serde::de::Error::custom(
"project tempo must be a finite positive number",
)),
}
Comment thread
seonghobae marked this conversation as resolved.
}

/// Current on-disk project format version, independent of the app version.
pub const CURRENT_PROJECT_FORMAT_VERSION: u16 = 1;

/// Versioned project envelope. The song remains the compatibility view until
/// source, derived, decision, handoff, preference, and runtime fields are
/// promoted into typed sections in a later format version.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProjectFilePayload {
project_format_version: u16,
song: RehearsalSongPayload,
}

/// Score attachment metadata persisted inside the song payload. Only the
/// locally minted score id and the display file name cross the IPC boundary;
/// the PDF bytes stay in the app-owned scores directory keyed by that id.
Expand Down Expand Up @@ -528,12 +565,25 @@ pub fn is_youtube_video_id(value: &str) -> bool {
}

pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayload, String> {
if let Ok(parsed) = serde_json::from_str::<RehearsalSongPayload>(content) {
let payload = serde_json::from_str::<Value>(content)
.map_err(|_| "Invalid project file format".to_string())?;

if let Some(version_value) = payload.get("projectFormatVersion") {
let version = version_value
.as_u64()
.ok_or_else(|| "Invalid project file format".to_string())?;
if version != u64::from(CURRENT_PROJECT_FORMAT_VERSION) {
return Err(format!("Unsupported project format version: {version}"));
}
let envelope = serde_json::from_value::<ProjectFilePayload>(payload)
.map_err(|_| "Invalid project file format".to_string())?;
return Ok(envelope.song);
Comment thread
seonghobae marked this conversation as resolved.
}

if let Ok(parsed) = serde_json::from_value::<RehearsalSongPayload>(payload.clone()) {
return Ok(parsed);
}

let payload = serde_json::from_str::<Value>(content)
.map_err(|_| "Invalid project file format".to_string())?;
if let Some(sections) = payload.get("sections").and_then(Value::as_array) {
for (section_index, section) in sections.iter().enumerate() {
if section
Expand All @@ -550,6 +600,15 @@ pub fn project_payload_from_content(content: &str) -> Result<RehearsalSongPayloa
serde_json::from_value(payload).map_err(|_| "Invalid project file format".to_string())
}

/// Serialize one validated song into the current versioned project envelope.
pub fn project_content_for_payload(payload: &RehearsalSongPayload) -> Result<String, String> {
serde_json::to_string_pretty(&ProjectFilePayload {
project_format_version: CURRENT_PROJECT_FORMAT_VERSION,
song: payload.clone(),
})
.map_err(|_| "Failed to serialize project file format".to_string())
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScoreAttachmentPayload {
Expand Down Expand Up @@ -869,6 +928,95 @@ mod tests {
assert_eq!(parsed.title, "Late Night Set");
}

#[test]
fn project_format_v1_round_trips_the_song_and_tempo() {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = json!(120.0);
let song = serde_json::from_value::<RehearsalSongPayload>(payload)
.expect("song payload should deserialize");

let content = project_content_for_payload(&song).expect("v1 project should serialize");
let encoded: Value = serde_json::from_str(&content).expect("v1 project should be JSON");
assert_eq!(
encoded["projectFormatVersion"],
json!(CURRENT_PROJECT_FORMAT_VERSION)
);
assert_eq!(encoded["song"]["tempo"], json!(120.0));

let parsed = project_payload_from_content(&content).expect("v1 project should load");
assert_eq!(parsed.title, "Late Night Set");
assert_eq!(parsed.tempo, Some(120.0));
}

#[test]
fn project_format_v1_fixture_is_loadable() {
let parsed = project_payload_from_content(include_str!("../testdata/project-v1.json"))
.expect("the checked-in v1 fixture should load");

assert_eq!(parsed.id, "fixture-song");
assert_eq!(parsed.tempo, Some(96.0));
}

#[test]
fn project_format_rejects_unknown_fields_and_unsupported_versions() {
let payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
let mut envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION,
"song": payload
});
envelope["unexpected"] = json!(true);
assert_eq!(
project_payload_from_content(&envelope.to_string())
.expect_err("unknown fields fail closed"),
"Invalid project file format"
);

let supported_payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
let supported_envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1,
"song": supported_payload
});
assert_eq!(
project_payload_from_content(&supported_envelope.to_string())
.expect_err("unsupported version should be explicit"),
"Unsupported project format version: 2"
);

let future_envelope = json!({
"projectFormatVersion": CURRENT_PROJECT_FORMAT_VERSION + 1,
"futureEnvelopeField": true,
"song": { "futureSongField": "new schema" }
});
assert_eq!(
project_payload_from_content(&future_envelope.to_string())
.expect_err("future schema should report its unsupported version"),
"Unsupported project format version: 2"
);
}

#[test]
fn project_format_rejects_invalid_tempo_values() {
for invalid_tempo in [json!(null), json!(0), json!(-10), json!("120")] {
let mut payload = shared_contract_payload(json!({ "start": 10, "end": 30 }));
payload["tempo"] = invalid_tempo;
assert!(
serde_json::from_value::<RehearsalSongPayload>(payload).is_err(),
"invalid tempo should fail closed"
);
}

assert!(
project_payload_from_content(
&format!(
r#"{{"projectFormatVersion":{},"song":{{"id":"song","title":"Song","tempo":1e999,"sections":[],"exportSummary":{{}}}}}}"#,
CURRENT_PROJECT_FORMAT_VERSION
)
)
.is_err(),
"non-finite JSON numbers should fail closed"
);
}

#[test]
fn project_payload_from_content_rejects_malformed_or_incomplete_payloads() {
assert_eq!(
Expand Down
67 changes: 67 additions & 0 deletions apps/desktop/core/testdata/project-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"projectFormatVersion": 1,
"song": {
"id": "fixture-song",
"title": "Fixture Rehearsal",
"tempo": 96,
"sections": [
{
"id": "verse-1",
"label": "verse",
"groove": "Straight eighths",
"timeRange": {
"start": 0,
"end": 4
},
"confidence": {
"level": "medium",
"source": "model",
"notes": "Check the entrance."
},
"roles": [
{
"id": "bass-guitar",
"name": "Bass Guitar",
"roleType": "instrument",
"harmony": {
"chord": "C",
"functionLabel": "tonic",
"source": "model"
},
"cue": {
"kind": "transition",
"value": "Enter on the downbeat."
},
"range": {
"lowestNote": "C2",
"highestNote": "G3"
},
"confidence": {
"level": "medium",
"source": "model",
"notes": ""
},
"rehearsalPriority": "high",
"simplification": "Play roots.",
"setupNote": "Keep the attack short.",
"manualOverrides": [],
"overlapWarnings": []
}
],
"partGraph": [
{
"role_id": "bass-guitar",
"is_active": true,
"handoff_to": [],
"handoff_from": []
}
]
}
],
"exportSummary": {
"format": "cue-sheet",
"headline": "Start with the verse.",
"focusSections": ["verse-1"]
}
}
}
16 changes: 7 additions & 9 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod project_persistence;

use bandscope_desktop_core::*;
use rfd::FileDialog;
use serde_json::{json, Value};
Expand Down Expand Up @@ -748,9 +750,9 @@ fn save_project(payload: Value) -> Result<(), String> {
.save_file()
.ok_or_else(|| "User cancelled".to_string())?;

let content = serde_json::to_string_pretty(&parsed)
.map_err(|_| "Failed to serialize project".to_string())?;
std::fs::write(path, content).map_err(|_| "Failed to write file".to_string())?;
let content = project_content_for_payload(&parsed)?;
project_persistence::recover_project_publication(&path)?;
project_persistence::publish_new_project_file(&path, content.as_bytes())?;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

Ok(())
}
Expand All @@ -762,12 +764,8 @@ fn load_project() -> Result<RehearsalSongPayload, String> {
.pick_file()
.ok_or_else(|| "User cancelled".to_string())?;

let metadata = std::fs::metadata(&path).map_err(|_| "Failed to read file".to_string())?;
if metadata.len() > 5 * 1024 * 1024 {
return Err("Project file is too large (exceeds 5MB limit)".to_string());
}

let content = std::fs::read_to_string(path).map_err(|_| "Failed to read file".to_string())?;
project_persistence::recover_project_publication(&path)?;
Comment thread
seonghobae marked this conversation as resolved.
let content = project_persistence::read_project_file(&path)?;
project_payload_from_content(&content)
}

Expand Down
Loading
Loading