diff --git a/Makefile b/Makefile index 91446d5..cc6a5f8 100644 --- a/Makefile +++ b/Makefile @@ -417,3 +417,16 @@ test-parameters: $(BUILD_DIR)/test_parameters ./$(BUILD_DIR)/test_parameters test: test-parameters -include $(BUILD_DIR)/tests/unit/test_parameters.d + +$(BUILD_DIR)/test_project_diagnostics: $(BUILD_DIR)/tests/unit/test_project_diagnostics.o $(BUILD_DIR)/libxtc.a + $(CC) $(LDFLAGS) -o $@ $^ -lm -ldl -Wl,--wrap=fflush,--wrap=fclose,--wrap=fsync,--wrap=rename +.PHONY: test-project-diagnostics +test-project-diagnostics: $(BUILD_DIR)/test_project_diagnostics + ./$(BUILD_DIR)/test_project_diagnostics +test: test-project-diagnostics +-include $(BUILD_DIR)/tests/unit/test_project_diagnostics.d + +.PHONY: test-project-inspection +test-project-inspection: $(BUILD_DIR)/xtc-check + $(PYTHON) tests/integration/test_project_inspection.py $(BUILD_DIR)/xtc-check +test: test-project-inspection diff --git a/README.md b/README.md index 4b13b07..5e8a344 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,11 @@ per-channel live MIDI CC bindings. Project v10 reads v1–v9. See Phase 27 reserves undo storage before edits/publication, groups completed takes, preserves surviving editor selections and improves context menus/focus feedback. See [EDITING.md](docs/EDITING.md) for the workflow audit and failure boundaries. + +Project troubleshooting: `xtc-check --validate PROJECT.xtc` reports metadata +errors with the expected record and syntax line. `xtc-check --inspect PROJECT.xtc` +lists resolved media/module paths and plugin identities without executing plugins +or starting JACK. Exit codes: 0 = valid metadata and available files, 2 = missing +or unreadable references, 1 = metadata/I/O error. File availability does not +certify WAV contents or plugin compatibility. See [PROJECT_FORMAT.md](docs/PROJECT_FORMAT.md) +for the compatibility and relocation policy. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 6bcea9d..ede5e6a 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -65,3 +65,11 @@ PERFORMANCE.md, PLUGIN_HOSTING.md and RECOVERY.md for tested contracts/evidence. or cancellable progress. Surviving selections are retained across undo but historical selection/layout is not stored. Native context-menu/focus manual qualification and broad accessibility testing remain pending. See EDITING.md. + +Phase 28 project diagnostics use syntax-line locations; semantic cross-reference +failures identify the MODEL stage without a source line. `xtc-check --inspect` +checks dependency file availability only, without loading code or WAV contents. +The project format is Linux/POSIX oriented; absolute plugin paths, architecture, +ABI and JACK/device configuration must be supplied on another system. Unknown +fields are rejected, not preserved. See PROJECT_FORMAT.md for versions 1–10 and +RECOVERY.md for save failures after rename. diff --git a/docs/PROJECT_FORMAT.md b/docs/PROJECT_FORMAT.md index 715bf4c..a59fdf4 100644 --- a/docs/PROJECT_FORMAT.md +++ b/docs/PROJECT_FORMAT.md @@ -1,6 +1,6 @@ # Project format v10 (reads v1–v9) -Phases 5–26 use a bounded text format implemented in C99 without a parser library. +Phases 5–28 use a bounded text format implemented in C99 without a parser library. The extension is `.xtc`. File operations run exclusively on the control thread. The project owns its graph, tracks and silent/audio arrangement regions. Audio samples remain in external WAV files; MIDI notes are stored inline. @@ -104,8 +104,8 @@ node types 1..4, migrating their clips to empty asset paths. Version 2 accepts node types 1..5 and ASSET records. Both migrate to MIDI=0 and no notes. Version 3 accepts instrument nodes and MIDI/NOTES records. Versions 1–3 migrate all tracks to unity gain, centered pan, mute/solo off and no automation. -Saving always writes v7. Unknown versions are rejected without replacing the destination; older -applications cannot read v7. Migration and maximum-path roundtrips are tested. +Saving always writes v10. Unknown versions are rejected without replacing the destination; older +applications without v10 support cannot read current saves. Migration and maximum-path roundtrips are tested. ## Ownership and failures @@ -114,8 +114,9 @@ the destination. Any failure retains the complete previous project. Saving validates the project, creates a mode-0600 temporary in the destination directory, writes, flushes, fsyncs, closes and renames over the destination. Failures before rename retain the previous destination and remove the temporary. -The parent directory is not fsynced: survival of the rename through sudden power -loss is not guaranteed. Save replaces symlinks themselves and does not preserve +After rename, the parent directory is fsynced. A directory-sync failure reports +I/O error even though the new complete destination may already be present. +Storage and filesystem behavior still determine power-loss durability. Save replaces symlinks themselves and does not preserve existing file permissions. POSIX I/O is private to the Linux-first persistence module; public headers contain no POSIX types. @@ -123,7 +124,8 @@ Saved data reproduces the arrangement, graph, track mix and automation, includin ID allocation state. Undo history is not saved. See MIXER.md for pan/interpolation. Graph node positions, timeline zoom/scroll/selection, transport position and DSP oscillator phase are GUI/session state and are not saved. Asset references are -external absolute paths, not copied on Save As. Metadata loading preserves +absolute in memory; saves use relative paths for media inside the project folder, +otherwise absolute paths. Media are not copied on Save As. Metadata loading preserves missing references; audio-plan preparation reports unreadable/invalid assets and renders them silently. Relinking replaces a selected clip's path without changing its IDs or arrangement. See AUDIO_FILES.md for recovery and limitations. @@ -139,7 +141,7 @@ writer and unfinished-recording behavior. Metadata validation is available via ## Phase 15: version 5 plugin metadata -The writer now emits `XTC_PROJECT 5`; versions 1–4 still load. All v4 records and +Phase 15 introduced `XTC_PROJECT 5`; its reader also supported versions 1–4. All v4 records and order remain the same. Node type 8 is a LADSPA plugin; native pointer-bearing type 7 is still unsupported in files. Immediately after each type-8 `NODE id 8 00000000` record come: @@ -163,7 +165,7 @@ stored. See PLUGIN_HOSTING.md and decision 0009 for supported processors/limits. ## Phase 16: version 6 -Writer emits v6 and readers accept v1–v6. After each plugin's CONTROL records, +Phase 16 introduced v6 and its reader accepted v1–v6. After each plugin's CONTROL records, `PLUGIN_AUTOMATION count` is followed by exactly count (<=32) records: `PARAM tick port mode float32_hex_bits`. Mode is 0 linear or 1 step. Duplicate port/tick pairs, nonfinite values, unknown control ports and ticks beyond the @@ -205,7 +207,7 @@ the selected document. In-memory models and the existing destination are preserv on load failure. No automatic unknown-field stripping or media deletion occurs. `make test-portability test test-plugins test-recovery test-recording` covers checked-in -v1–v6 fixtures (including missing v5/v6 plugins), v7 exact roundtrips, moved-folder +v1–v10 fixtures (including missing v5/v6 plugins), current exact roundtrips, moved-folder playback from a different cwd, missing media retention, unknown records, every truncation and deterministic byte mutations, malformed plugin metadata, interrupted writers and write-limit failures. ASan/UBSan/static analysis additionally audit the @@ -240,7 +242,7 @@ format change (`make test test-portability test-audio-editing`). ## Phase 24: version 9 mix routing -The writer emits v9. Immediately after each TRACK's MIX record it writes +Phase 24 introduced v9. Immediately after each TRACK's MIX record it writes `ROUTE is_bus bus_id`. `is_bus` is 0 or 1; `bus_id` is an existing bus track ID, or decimal UINT64_MAX (18446744073709551615) for master. Buses must route to master and cannot contain clips. Invalid destinations, flags and nested routing @@ -266,6 +268,72 @@ bindings and invalid interpolation modes reject the complete candidate. v1–v9 supply default 7/10/64 bindings on every channel. No event history/current live controller values are serialized. Older readers reject v10. -Fixtures cover v1–v9 migration. Exact roundtrips, malformed metadata, step/linear +Fixtures cover v1–v10 loading and migration. Exact roundtrips, malformed metadata, step/linear renders and remapped live events cover the new writer/reader. Public clients must rebuild and initialize the appended `xtc_automation_point.step` field. + +## Phase 28 compatibility and diagnostic contract + +The current reader accepts versions 1–10 and the writer always emits version 10. +There is no format change in Phase 28. Saving an older document upgrades it; +retain an original backup if an older application must still open it. APP is +informational; compatibility depends on the format version, not the producer's +application version. A future incompatible schema requires a new format number. +Public C/C++ consumers must rebuild when public structs change. + +| Version | Added representation | Defaults when loading earlier versions | +| --- | --- | --- | +| 1 | Graph, tracks, silent regions | Baseline | +| 2 | External WAV references | Empty asset references | +| 3 | MIDI clips and notes | Non-MIDI clips, no notes | +| 4 | Track mix and automation | Unity gain, center pan, mute/solo off, no points | +| 5 | LADSPA metadata | No plugin nodes | +| 6 | Plugin automation | Empty plugin lanes | +| 7 | Relative media references | Earlier media paths must be absolute | +| 8 | Clip gain and fades | Unity gain, zero explicit fades | +| 9 | Bus routing | Ordinary tracks routed directly to master | +| 10 | Track interpolation and controller bindings | Linear points; CC 7/10/64 on every channel | + +The grammar has no optional extension namespace. Unknown records, extra fields, +node types or trailing data cannot be preserved by this model and are rejected +rather than silently removed. Supported older records migrate in a separate +candidate and pass complete model validation before adoption. Failed parsing, +reference validation or close retains the existing destination model. GUI Open +also prepares the candidate before replacing the live document; missing or +incompatible plugins retain the current document and identify the module. +Missing media references remain available for relinking and render silently. + +`xtc_project_load_diagnostic` adds a copied result, parsed format version (zero +if unavailable), expected record/stage and message. Syntax locations are 1-based; +line zero denotes I/O, size, path resolution or whole-model validation. MODEL +errors do not identify the original line of a cross-reference. The original +`xtc_project_load` remains available with identical transactional semantics. + +`xtc-check --validate PROJECT` checks bounded metadata without loading dependencies. +`xtc-check --inspect PROJECT` additionally lists resolved media and module paths, +plugin labels, unique IDs and descriptor indices. It checks readable regular-file +availability only. Exit status is 0 when references are available, 2 when valid +metadata has missing/unreadable references, and 1 for invalid metadata or I/O. +Neither command executes plugin code, decodes WAV data, connects JACK or verifies +hardware. A readable file can still be an invalid WAV or incompatible plugin; +actual preparation performs those checks. Paths and labels are escaped for +terminal output. There is no search-path substitution or automatic plugin relink. + +Portability currently targets Linux/POSIX. The text representation is independent +of byte order and locale and requires IEEE binary32 floats. IDs used by the graph +must fit the receiving host's size_t; a 64-bit file is not guaranteed to fit a +32-bit build. Paths follow POSIX semantics; Windows drive letters and separators +are not translated. Relative media can travel with their project folder, while +absolute references and symlink targets must be restored separately. LADSPA +modules are absolute installed references: match architecture, ABI, descriptor +identity, ports and legal controls on the destination. Plugin binaries and state +are not bundled. JACK availability, sample rate, block limits and device routing +are runtime dependencies; loading metadata does not restore device connections. + +`make test` includes v1–v10 migration, malformed/truncated/mutated/oversized input, +retained-state diagnostics, CLI dependency inspection, and deterministic save +faults at flush, file sync, close, rename and directory sync. Pre-rename faults +preserve the old bytes and remove the temporary. After rename, a sync failure +reports I/O while leaving a complete new document; inspect it before retrying. +Fault wrappers are linked into tests only. These tests complement killed-writer +coverage in `make test-recovery`; they do not qualify power-cut behavior. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 9da97cf..cb1cc2b 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -1,6 +1,6 @@ # Persistence and failure recovery -The project format remains v4. Readers migrate v1/v2/v3, validate bounded input +The project format is v10. Readers migrate v1–v9, validate bounded input and atomically install a candidate only after complete success. Existing tests cover exact migration/roundtrip, truncations, malformed references, counts, IDs, nonfinite values and failed GUI/backend installation. Native processors @@ -10,22 +10,26 @@ process addresses or an unreadable project. See PROJECT_FORMAT.md. Project Save writes a mode-0600 same-directory `NAME.tmp.XXXXXX`, flushes and fsyncs it, closes and renames it over the destination. Before rename, handled failures remove the temporary and retain the destination. POSIX rename gives -complete old-or-new destination contents during process failure. The directory -is not fsynced, so this is not a power-loss durability guarantee. Symlinks are +complete old-or-new destination contents during process failure. The containing directory is fsynced after rename. If that sync fails, Save +reports I/O failure but the complete new file may already be present. Preserve +and validate it before retrying; filesystem/hardware durability still varies. Symlinks are replaced rather than followed at rename; original file permissions are not kept. Recording similarly writes/finalizes a same-directory temporary, then renames it. Stop must synchronize callbacks before draining/finalizing. Ring overflow returns an explicit error and may finalize a partial take for manual recovery; I/O failure retains the previous destination. A killed recorder does not finish -its WAV header. Source WAV files remain external absolute paths; Save As does -not collect them. Back up the project and assets together. +its WAV header. Source WAV files remain external; references inside the project folder are +saved relative to it. Save As does not collect them. Back up the project and assets together. ## After a crash 1. Preserve the destination and any `.tmp.*` files before attempting repair. 2. Run `xtc-check --validate project.xtc` for metadata validation without a GUI, - display or audio server. This does not verify assets or realtime preparation. + display or audio server. Run `xtc-check --inspect project.xtc` to list resolved + media/module paths and plugin identities: status 2 means missing/unreadable + references, 1 means invalid metadata/I/O, 0 means files are available. Neither + command verifies file contents, plugin initialization or realtime preparation. 3. A valid destination can be opened normally. If only a temporary contains useful data, copy it to a separate candidate and validate that copy before replacing anything. Partial project files are rejected; there is no salvage parser. @@ -75,3 +79,13 @@ retains the original graph, controls and history position. Cancel in the generic parameter editor discards its temporary values. Reset defaults clears automation only when applied. No host recovery can contain a crash or hang in trusted native plugin code; LADSPA has no runtime error-return channel. + +## Save diagnostics (Phase 28) + +`make test-project-diagnostics` deterministically injects flush, file-sync, close, +rename and directory-sync errors. Pre-rename failures retain the previous bytes +and clean temporary files. Post-rename sync errors leave a complete new file and +return failure; there is no rollback after replacement. Load diagnostics identify +the expected record and syntax line, or line zero for whole-model/I/O stages. +Unknown versions report the supported range (1–10); unknown records/fields are +rejected without partially changing the current project. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f123907..09f65ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -774,6 +774,18 @@ Make project files robust, diagnosable, and maintainable across application vers - Version incompatibilities and missing dependencies are clearly reported. - Supported project versions have migration and regression tests. +### Implementation status +Completed: version-10 compatibility policy and v1–v10 fixture migration; +transactional diagnostic loading in the core and GUI; metadata-only dependency +inspection with explicit availability limits; relocation and unknown-field +policies; bounded-input and deterministic pre/post-rename save-failure coverage. +No format bump was needed. Linux/POSIX, plugin ABI/path and audio-backend limits +are documented in PROJECT_FORMAT.md and RECOVERY.md. + +Validation: full core/GUI/plugin/JACK/workflow/recording/recovery tests, static +analysis, staged installation and binary packaging; minimal no-JACK/no-LADSPA +build and tests; ASan/UBSan core, reliability and recovery tests. + ## Phase 29 — Performance, Scalability, and Stress Testing ### Goal diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index d4b0a97..46807a5 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -152,3 +152,11 @@ right-click no longer immediately deletes notes. Graph right-click targets the node/wire under the pointer. Undo preserves surviving selections. Completed takes add one undo step; undo removes references and keeps WAV files. See EDITING.md for grouped commands, failure handling and keyboard/focus behavior. + +Project troubleshooting: `xtc-check --validate PROJECT.xtc` reports metadata +errors with the expected record and syntax line. `xtc-check --inspect PROJECT.xtc` +lists resolved media/module paths and plugin identities without executing plugins +or starting JACK. Exit codes: 0 = valid metadata and available files, 2 = missing +or unreadable references, 1 = metadata/I/O error. File availability does not +certify WAV contents or plugin compatibility. See [PROJECT_FORMAT.md](PROJECT_FORMAT.md) +for the compatibility and relocation policy. diff --git a/include/xtc/xtc_project.h b/include/xtc/xtc_project.h index cff6b95..de3c222 100644 --- a/include/xtc/xtc_project.h +++ b/include/xtc/xtc_project.h @@ -224,6 +224,17 @@ xtc_result xtc_project_sample_to_tick(const xtc_project *project, uint64_t sampl * @return XTC_OK on success, or an error code describing the failure. */ xtc_result xtc_project_save(const xtc_project *project, const char *path); +/* Optional copied control-thread diagnostics. line is 1-based for syntax errors, + * 0 for I/O/whole-model validation; record identifies the expected record or + * validation stage, never untrusted file text. format_version is 0 if unknown. + * No media/plugin loading, engine creation or mutation on load failure. */ +typedef struct { + xtc_result result; + uint64_t format_version; + size_t line; + char record[32], message[192]; +} xtc_project_diagnostic; +xtc_result xtc_project_load_diagnostic(xtc_project *project, const char *path, xtc_project_diagnostic *diagnostic); /** * @brief Loads and validates a project before transactionally replacing the current state. * diff --git a/src/gui/main_window.cpp b/src/gui/main_window.cpp index f543eee..b97cf53 100644 --- a/src/gui/main_window.cpp +++ b/src/gui/main_window.cpp @@ -961,8 +961,14 @@ xtc_result MainWindow::open_project(const char *path) std::string loaded_path(path); xtc_project *candidate = nullptr; xtc_result r = xtc_project_create(&candidate); - if (r == XTC_OK) - r = xtc_project_load(candidate, path); + xtc_project_diagnostic diagnostic{}; + if (r == XTC_OK) { + r = xtc_project_load_diagnostic(candidate,path,&diagnostic); + if (r != XTC_OK) { + project_issue_ = "Project load line " + std::to_string(diagnostic.line) + " (" + diagnostic.record + "): " + diagnostic.message; + report_error(project_issue_.c_str(),r); + } + } if (r == XTC_OK) r = install_project(candidate); if (r != XTC_OK) { diff --git a/src/main.c b/src/main.c index 45ad308..038056a 100644 --- a/src/main.c +++ b/src/main.c @@ -1,3 +1,4 @@ +#define _POSIX_C_SOURCE 200809L #include "xtc/xtc.h" #include "xtc/xtc_version.h" @@ -8,11 +9,67 @@ #include "xtc/xtc_audio.h" #include "xtc/xtc_midi.h" #include +#include +#include +#include +#include "xtc/xtc_plugin.h" + +/* Dependency inspection checks readable regular files, never plugin code or + * audio contents. Escape untrusted path/label bytes in terminal output. */ +static void quoted(const char *text) +{ + const unsigned char *p = (const unsigned char *)text; + putchar('"'); + for (; *p; ++p) { + if (*p == '"' || *p == '\\') printf("\\%c",*p); + else if (*p < 32 || *p == 127) printf("\\x%02x",(unsigned int)*p); + else putchar(*p); + } + putchar('"'); +} +static int readable_file(const char *path) +{ + struct stat info; + return stat(path,&info) == 0 && S_ISREG(info.st_mode) && access(path,R_OK) == 0; +} +static int inspect(const xtc_project *project, uint64_t version) +{ + size_t tracks = 0, clips = 0, nodes = 0, edges = 0, i, missing = 0; + const xtc_graph *graph = xtc_project_graph(project); + xtc_project_get_counts(project,&tracks,&clips); + xtc_graph_get_counts(graph,&nodes,&edges); + printf("Project format %" PRIu64 " (writer %u): %zu tracks/buses, %zu clips, %zu graph nodes, %zu edges.\n", + version,XTC_PROJECT_FORMAT_VERSION,tracks,clips,nodes,edges); + for (i = 0; i < clips; ++i) { + xtc_clip_info clip; + xtc_project_get_clip(project,i,&clip); + if (clip.asset_path[0]) { + int available = readable_file(clip.asset_path); + printf("Media clip %" PRIu64 ": %s ",clip.id,available ? "available" : "missing/unreadable"); + quoted(clip.asset_path); putchar('\n'); + if (!available) ++missing; + } + } + for (i = 0; i < nodes; ++i) { + xtc_node_info node; xtc_plugin_spec spec; + xtc_graph_get_node(graph,i,&node); + if (xtc_graph_get_plugin(graph,node.id,&spec) == XTC_OK) { + int available = readable_file(spec.path); + printf("LADSPA node %zu ID %u descriptor %u ",node.id,spec.unique_id,spec.index); + quoted(spec.label); printf(": %s ",available ? "available" : "missing/unreadable"); + quoted(spec.path); putchar('\n'); + if (!available) ++missing; + } + } + printf("Dependency references missing/unreadable: %zu.\n",missing); + puts("File availability only: WAV contents, plugin identity/initialization, JACK and hardware not checked; no plugin code executed."); + return missing ? 2 : EXIT_SUCCESS; +} /** * @brief Runs the xtc-check command-line entry point. * - * Supports --version, --help, and --validate PROJECT. With no arguments it + * Supports version/help, metadata validation and dependency inspection. With no arguments it * creates and destroys the application shell to check core startup/shutdown. * Any other argument count prints usage to stderr. * @@ -38,7 +95,7 @@ int main(int argc, char **argv) return EXIT_SUCCESS; } if (argc == 2 && !strcmp(argv[1], "--help")) { - puts("Usage: xtc-check [--version | --help | --diagnose | --validate PROJECT | --recover-recording TEMP NEW.wav]\nNo arguments checks core startup/shutdown. " + puts("Usage: xtc-check [--version | --help | --diagnose | --validate PROJECT | --inspect PROJECT | --recover-recording TEMP NEW.wav]\nNo arguments checks core startup/shutdown. " "Validation checks metadata only."); return EXIT_SUCCESS; } @@ -52,21 +109,28 @@ int main(int argc, char **argv) puts("Complete recorded frames recovered; source retained."); return EXIT_SUCCESS; } - if (argc == 3 && !strcmp(argv[1], "--validate")) { + if (argc == 3 && (!strcmp(argv[1], "--validate") || !strcmp(argv[1], "--inspect"))) { xtc_project *project = NULL; + xtc_project_diagnostic diagnostic = {0}; xtc_result result = xtc_project_create(&project); - if (result == XTC_OK) - result = xtc_project_load(project, argv[2]); - xtc_project_destroy(project); + if (result == XTC_OK) result = xtc_project_load_diagnostic(project,argv[2],&diagnostic); if (result != XTC_OK) { - fprintf(stderr, "Project validation failed: %s\n", xtc_result_string(result)); + fprintf(stderr,"Project validation failed: %s; line %zu, %s: %s\n",xtc_result_string(result), + diagnostic.line,diagnostic.record,diagnostic.message); + xtc_project_destroy(project); return EXIT_FAILURE; } + if (!strcmp(argv[1],"--inspect")) { + int code = inspect(project,diagnostic.format_version); + xtc_project_destroy(project); + return code; + } + xtc_project_destroy(project); puts("Project metadata valid (external assets not loaded)."); return EXIT_SUCCESS; } if (argc != 1) { - fputs("Usage: xtc-check [--version | --help | --diagnose | --validate PROJECT | --recover-recording TEMP NEW.wav]\n", stderr); + fputs("Usage: xtc-check [--version | --help | --diagnose | --validate PROJECT | --inspect PROJECT | --recover-recording TEMP NEW.wav]\n", stderr); return EXIT_FAILURE; } diff --git a/src/persistence/project_file.c b/src/persistence/project_file.c index 4238146..b783ce8 100644 --- a/src/persistence/project_file.c +++ b/src/persistence/project_file.c @@ -12,10 +12,13 @@ #include #include #include +#include typedef struct { FILE *file; - size_t bytes; + size_t bytes, line_number, record_line; + uint64_t version; + char expected[32]; char line[1536]; char *tokens[12]; size_t count; @@ -33,6 +36,7 @@ static xtc_result line(reader *input) int byte; char *cursor; input->count = 0U; + input->record_line = ++input->line_number; while ((byte = fgetc(input->file)) != EOF) { if (++input->bytes > 4194304U) return XTC_ERR_FORMAT; @@ -166,7 +170,9 @@ static int decode_float(const char *text, float *value) #define READ(tag, total) \ do { \ - xtc_result read_result = line(input); \ + xtc_result read_result; \ + snprintf(input->expected, sizeof(input->expected), "%s", (tag)); \ + read_result = line(input); \ if (read_result != XTC_OK) \ return read_result; \ if (input->count != (total) || strcmp(input->tokens[0], (tag)) != 0) \ @@ -193,6 +199,7 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * xtc_graph *graph = project->graph; READ("XTC_PROJECT", 2U); NUM(1U, version); + input->version = version; if (version < 1U || version > XTC_PROJECT_FORMAT_VERSION) return XTC_ERR_UNSUPPORTED; format_version = version; @@ -435,6 +442,8 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * return XTC_ERR_FORMAT; if (ferror(input->file)) return XTC_ERR_IO; + input->record_line = 0; + snprintf(input->expected,sizeof(input->expected),"%s","ASSET_PATH"); if (format_version >= 7) for (i = 0; i < project->clip_count; ++i) { char *asset = project->clips[i].asset_path; if (asset[0] && asset[0] != '/') { @@ -444,6 +453,7 @@ static xtc_result read_project(reader *input, xtc_project *project, const char * memcpy(asset,directory,root); asset[root] = '/'; } } + snprintf(input->expected,sizeof(input->expected),"%s","MODEL"); return xtc_project_validate(project); } @@ -612,36 +622,64 @@ static char *project_folder(const char *path) * @param path NUL-terminated file or asset path. * @return XTC_OK on success, or an error code describing the failure. */ -xtc_result xtc_project_load(xtc_project *project, const char *path) +static xtc_result load_report(const reader *input, xtc_result result, xtc_project_diagnostic *out) +{ + xtc_project_diagnostic d = {0}; + d.result = result; + d.format_version = input->version; + d.line = result == XTC_OK ? 0 : input->record_line; + if (result != XTC_OK) snprintf(d.record,sizeof(d.record),"%s",input->expected); + if (result == XTC_OK) snprintf(d.message,sizeof(d.message),"Metadata valid; external media/plugins not loaded."); + else if (result == XTC_ERR_UNSUPPORTED && !strcmp(input->expected,"XTC_PROJECT")) + snprintf(d.message,sizeof(d.message),"Project format %" PRIu64 " is unsupported; this build reads versions 1..%u.",input->version,XTC_PROJECT_FORMAT_VERSION); + else if (result == XTC_ERR_UNSUPPORTED) snprintf(d.message,sizeof(d.message),"Unsupported project feature or node type; no data was discarded."); + else if (result == XTC_ERR_FORMAT && !strcmp(input->expected,"SIZE")) snprintf(d.message,sizeof(d.message),"Project exceeds the 4 MiB input limit."); + else if (result == XTC_ERR_FORMAT && !strcmp(input->expected,"MODEL")) snprintf(d.message,sizeof(d.message),"Invalid project references, IDs, graph, routing or values."); + else if (result == XTC_ERR_FORMAT) snprintf(d.message,sizeof(d.message),"Invalid, unknown or truncated record; expected %s. Unknown fields are rejected.",input->expected); + else if (result == XTC_ERR_IO) snprintf(d.message,sizeof(d.message),"Cannot read project or resolve its containing directory; check path and permissions."); + else if (result == XTC_ERR_CAPACITY) snprintf(d.message,sizeof(d.message),"Project capacity or resolved media path exceeds supported limits."); + else snprintf(d.message,sizeof(d.message),"Project load failed: %s.",xtc_result_string(result)); + if (out) *out = d; + return result; +} +xtc_result xtc_project_load_diagnostic(xtc_project *project, const char *path, xtc_project_diagnostic *diagnostic) { xtc_project *candidate = NULL; reader input = {0}; char *directory; xtc_result result; + struct stat info; + snprintf(input.expected,sizeof(input.expected),"%s","OPEN"); if (project == NULL || path_length(path) == 0U) - return XTC_ERR_INVALID_ARGUMENT; + return load_report(&input,XTC_ERR_INVALID_ARGUMENT,diagnostic); input.file = fopen(path, "rb"); - if (input.file == NULL) - return XTC_ERR_IO; + if (input.file == NULL) return load_report(&input,XTC_ERR_IO,diagnostic); + if (fstat(fileno(input.file),&info) == 0 && S_ISREG(info.st_mode) && info.st_size > 4194304) { + snprintf(input.expected,sizeof(input.expected),"%s","SIZE"); + fclose(input.file); + return load_report(&input,XTC_ERR_FORMAT,diagnostic); + } { char *canonical = realpath(path,NULL); directory = canonical ? project_folder(canonical) : NULL; free(canonical); } - if (!directory) { fclose(input.file); return XTC_ERR_IO; } + if (!directory) { fclose(input.file); return load_report(&input,XTC_ERR_IO,diagnostic); } result = xtc_project_create(&candidate); - if (result == XTC_OK) - result = read_project(&input, candidate, directory); + if (result == XTC_OK) result = read_project(&input, candidate, directory); free(directory); - if (fclose(input.file) != 0 && result == XTC_OK) - result = XTC_ERR_IO; + if (fclose(input.file) != 0 && result == XTC_OK) result = XTC_ERR_IO; if (result == XTC_OK) { xtc_graph *old = project->graph; *project = *candidate; candidate->graph = old; } xtc_project_destroy(candidate); - return result; + return load_report(&input,result,diagnostic); +} +xtc_result xtc_project_load(xtc_project *project, const char *path) +{ + return xtc_project_load_diagnostic(project,path,NULL); } /** diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 55e8247..1a3dadd 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -3,7 +3,7 @@ Store small, redistributable fixtures here. Document their origin and license. Do not commit large recordings or assets with unclear licensing. -`project-v1.xtc` through `project-v9.xtc` are hand-authored xtc metadata fixtures +`project-v1.xtc` through `project-v10.xtc` are hand-authored xtc metadata fixtures created for this repository, under the project license. They contain no audio or third-party resources. The v5/v6 plugin fixtures intentionally reference a missing module to exercise metadata migration without loading code. diff --git a/tests/fixtures/project-v10.xtc b/tests/fixtures/project-v10.xtc new file mode 100644 index 0000000..fc4d794 --- /dev/null +++ b/tests/fixtures/project-v10.xtc @@ -0,0 +1,39 @@ +XTC_PROJECT 10 +APP 0 1 0 +TEMPO 120000 +IDS 1 1 +GRAPH 1 2 2 1 +NODE 0 1 3f000000 +NODE 1 4 00000000 +EDGE 0 1 +TRACKS 1 +TRACK 0 4b657973 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +CLIPS 1 +CLIP 0 0 0 960 0 960 5665727365 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +NOTES 1 1 +NOTE 0 0 0 480 69 100 0 +AUTOMATION 1 +POINT 0 0 0 3f800000 0 +CONTROL_MAPS 16 +MAP 0 7 10 64 +MAP 1 7 10 64 +MAP 2 7 10 64 +MAP 3 7 10 64 +MAP 4 7 10 64 +MAP 5 7 10 64 +MAP 6 7 10 64 +MAP 7 7 10 64 +MAP 8 7 10 64 +MAP 9 7 10 64 +MAP 10 7 10 64 +MAP 11 7 10 64 +MAP 12 7 10 64 +MAP 13 7 10 64 +MAP 14 7 10 64 +MAP 15 7 10 64 +END diff --git a/tests/integration/test_portability.c b/tests/integration/test_portability.c index 01da632..a5f70b5 100644 --- a/tests/integration/test_portability.c +++ b/tests/integration/test_portability.c @@ -19,7 +19,7 @@ int main(void) assert(mkdtemp(root)); assert(xtc_project_create(&p) == XTC_OK); snprintf(file,sizeof(file),"%s/migration.xtc",root); - for (version = 1; version <= 9; ++version) { + for (version = 1; version <= 10; ++version) { xtc_track_info t; xtc_clip_info c; size_t notes, points; snprintf(old,sizeof(old),"tests/fixtures/project-v%u.xtc",version); assert(xtc_project_load(p,old) == XTC_OK); @@ -89,6 +89,6 @@ int main(void) assert(xtc_project_load(p,file) == XTC_ERR_FORMAT); { xtc_clip_info c; assert(xtc_project_find_clip(p,clip,&c) == XTC_OK && c.asset_path[0]); } } xtc_project_destroy(p); unlink(file); rmdir(moved); - puts("v1-v9 migration, current folder relocation, cwd independence and missing-media preservation passed."); + puts("v1-v10 migration, current folder relocation, cwd independence and missing-media preservation passed."); return 0; } diff --git a/tests/integration/test_project_inspection.py b/tests/integration/test_project_inspection.py new file mode 100644 index 0000000..22ddae5 --- /dev/null +++ b/tests/integration/test_project_inspection.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Metadata diagnostics and dependency inspection without a server or modules.""" +from pathlib import Path +import subprocess +import sys +import tempfile + +binary = str(Path(sys.argv[1]).resolve()) + +def check(option, path, code, *messages): + result = subprocess.run([binary, option, str(path)], capture_output=True, text=True) + assert result.returncode == code, (result.returncode, result.stdout, result.stderr) + for message in messages: + assert message in result.stdout + result.stderr, (message, result.stdout, result.stderr) + +base = Path("tests/fixtures/project-v10.xtc").read_text() +check("--inspect", "tests/fixtures/project-v10.xtc", 0, "Project format 10", "missing/unreadable: 0") +with tempfile.TemporaryDirectory(prefix="xtc-inspect-") as directory: + root = Path(directory) + project = root / "project.xtc" + media = root / "media.wav" + # Relative paths resolve against the project, independently of the subprocess cwd. + audio = base.replace("ASSET -", "ASSET " + b"media.wav".hex()).replace("MIDI 1", "MIDI 0") + audio = audio.replace("NOTES 1 1\nNOTE 0 0 0 480 69 100 0", "NOTES 0 0") + project.write_text(audio) + check("--validate", project, 0, "metadata valid") + check("--inspect", project, 2, "Media clip 0", str(media), "missing/unreadable: 1") + media.write_text("Deliberately not WAV data") + check("--inspect", project, 0, "available", "WAV contents", "not checked") + module = root / "module.so" + plugin = Path("tests/fixtures/project-plugin-v6.xtc").read_text() + plugin = plugin.replace(b"/missing/test.so".hex(), str(module).encode().hex()) + project.write_text(plugin) + check("--validate", project, 0) + check("--inspect", project, 2, "LADSPA node 0 ID 900000 descriptor 0", "xtc_test_gain", str(module)) + # Inspection succeeds for readable files without trying dlopen or decoding. + module.write_text("Deliberately not an executable module") + check("--inspect", project, 0, "no plugin code executed") + for text, messages in [ + (base.replace("XTC_PROJECT 10", "XTC_PROJECT 999"), ("line 1", "999", "1..10")), + ("XTC_PROJECT 10\n", ("line 2", "APP")), + (base.replace("TEMPO 120000", "TEMPO 0"), ("line 3", "TEMPO")), + (base + "OPTIONAL_FUTURE 1\n", ("Unknown fields",)), + (base.replace("APP 0 1 0", "APP 0 1 0 extra"), ("APP",)), + ]: + project.write_text(text) + check("--inspect", project, 1, *messages) + with project.open("wb") as stream: + stream.truncate(4194305) + check("--inspect", project, 1, "SIZE", "4 MiB") + check("--inspect", root / "absent.xtc", 1, "OPEN") +print("Project inspection availability, diagnostics and strict compatibility passed.") diff --git a/tests/unit/test_project_diagnostics.c b/tests/unit/test_project_diagnostics.c new file mode 100644 index 0000000..85281d7 --- /dev/null +++ b/tests/unit/test_project_diagnostics.c @@ -0,0 +1,112 @@ +#define _POSIX_C_SOURCE 200809L +#include "xtc/xtc_project.h" +#include +#include +#include +#include +#include +#include +#include +#include +/* Linker interposition injects errors only into this test executable. */ +static int fault; +int __real_fflush(FILE *file); +int __real_fclose(FILE *file); +int __real_fsync(int descriptor); +int __real_rename(const char *old, const char *next); +int __wrap_fflush(FILE *file) +{ + if (fault == 1) { errno = EIO; return EOF; } + return __real_fflush(file); +} +int __wrap_fclose(FILE *file) +{ + int result = __real_fclose(file); + if (fault == 4) { errno = EIO; return EOF; } + return result; +} +int __wrap_fsync(int descriptor) +{ + struct stat info; + assert(fstat(descriptor,&info) == 0); + if ((fault == 2 && S_ISREG(info.st_mode)) || (fault == 5 && S_ISDIR(info.st_mode))) { errno = EIO; return -1; } + return __real_fsync(descriptor); +} +int __wrap_rename(const char *old, const char *next) +{ + if (fault == 3) { errno = EIO; return -1; } + return __real_rename(old,next); +} +static void write_text(const char *path, const char *text) +{ + FILE *file = fopen(path,"wb"); assert(file); + assert(fwrite(text,1,strlen(text),file) == strlen(text)); assert(fclose(file) == 0); +} +static size_t bytes(const char *path, char out[4096]) +{ + FILE *file = fopen(path,"rb"); size_t n; + assert(file); n = fread(out,1,4096,file); assert(!ferror(file) && feof(file)); assert(fclose(file) == 0); return n; +} +static void no_temporaries(const char *path) +{ + DIR *directory = opendir(path); struct dirent *entry; assert(directory); + while ((entry = readdir(directory))) assert(!strstr(entry->d_name,".tmp.")); + assert(closedir(directory) == 0); +} +int main(void) +{ + char root[] = "/tmp/xtc-project-report-XXXXXX", song[256], bad[256]; + char before[4096], after[4096]; size_t size, n; + xtc_project *old = NULL, *next = NULL, *loaded = NULL; + xtc_project_diagnostic diagnostic; + xtc_track_info track; + uint64_t id; + int mode; + assert(mkdtemp(root)); + snprintf(song,sizeof(song),"%s/song.xtc",root); snprintf(bad,sizeof(bad),"%s/bad.xtc",root); + assert(xtc_project_create(&old) == XTC_OK && xtc_project_create(&next) == XTC_OK && xtc_project_create(&loaded) == XTC_OK); + assert(xtc_project_add_track(old,"Old",&id) == XTC_OK && xtc_project_add_track(next,"New",&id) == XTC_OK); + assert(xtc_project_save(old,song) == XTC_OK); size = bytes(song,before); + assert(xtc_project_load_diagnostic(loaded,song,&diagnostic) == XTC_OK && diagnostic.result == XTC_OK && + diagnostic.format_version == 10 && !diagnostic.line); + for (mode = 1; mode <= 5; ++mode) { + fault = mode; + assert(xtc_project_save(next,song) == XTC_ERR_IO); + fault = 0; + no_temporaries(root); + n = bytes(song,after); + if (mode < 5) assert(n == size && !memcmp(before,after,size)); + assert(xtc_project_load(loaded,song) == XTC_OK); + assert(xtc_project_get_track(loaded,0,&track) == XTC_OK && !strcmp(track.name,mode < 5 ? "Old" : "New")); + } + write_text(bad,"XTC_PROJECT 999\n"); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_UNSUPPORTED && diagnostic.line == 1 && + diagnostic.format_version == 999 && !strcmp(diagnostic.record,"XTC_PROJECT") && strstr(diagnostic.message,"1..10")); + write_text(bad,"XTC_PROJECT 10\nAPP 0 1 0\nTEMPO nope\n"); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 3 && + !strcmp(diagnostic.record,"TEMPO")); + write_text(bad,"XTC_PROJECT 10\n"); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 2 && + !strcmp(diagnostic.record,"APP")); + write_text(bad,"XTC_PROJECT 10\nAPP 0 1 0\nTEMPO 120000\nIDS 0 0\nGRAPH 1 1 1 0\nNODE 0 7 00000000\n"); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_UNSUPPORTED && diagnostic.line == 6 && + !strcmp(diagnostic.record,"NODE") && strstr(diagnostic.message,"node type")); + { + FILE *file = fopen(bad,"wb"); assert(file); + assert(ftruncate(fileno(file),4194305) == 0 && fclose(file) == 0); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && !diagnostic.line && + !strcmp(diagnostic.record,"SIZE") && strstr(diagnostic.message,"4 MiB")); + } + { + FILE *file = fopen(bad,"wb"); size_t i; assert(file); + for (i = 0; i < 1536; ++i) assert(fputc('A',file) != EOF); + assert(fputc('\n',file) != EOF && fclose(file) == 0); + assert(xtc_project_load_diagnostic(loaded,bad,&diagnostic) == XTC_ERR_FORMAT && diagnostic.line == 1); + } + assert(xtc_project_get_track(loaded,0,&track) == XTC_OK && !strcmp(track.name,"New")); + assert(xtc_project_load_diagnostic(loaded,"/absent/xtc-report.xtc",&diagnostic) == XTC_ERR_IO && !diagnostic.line); + xtc_project_destroy(old); xtc_project_destroy(next); xtc_project_destroy(loaded); + unlink(song); unlink(bad); assert(rmdir(root) == 0); + puts("Project load diagnostics, retained state, bounded input and pre/post-rename save faults passed."); + return 0; +}