1mod arrays;
8mod report;
9mod rietveld_wire;
10mod wire;
11
12use std::collections::BTreeMap;
13use std::error::Error;
14use std::fmt::{Display, Formatter};
15use std::fs;
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use arrays::{ArrayDescriptor, read_npz, sha256_hex, write_npz};
21use phasesmith_model::{DomainError, ProjectRecord};
22use phasesmith_workflows::RietveldProjectState;
23use serde::{Deserialize, Serialize};
24
25pub use report::{
26 HistogramSummary, PhaseSummary, ProjectReportSaveOptions, ProjectSummaryReport,
27 project_summary_json, write_project_summary_json, write_project_summary_json_with_options,
28};
29
30pub const PROJECT_FORMAT_VERSION: u32 = 2;
32pub const PROJECT_MANIFEST_NAME: &str = "manifest.json";
34pub const PROJECT_ARRAYS_NAME: &str = "arrays.npz";
36
37const MANIFEST_BACKUP_NAME: &str = ".manifest.json.phasesmith-backup";
38const ARRAYS_BACKUP_NAME: &str = ".arrays.npz.phasesmith-backup";
39
40static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct ProjectReadLimits {
45 pub max_manifest_bytes: u64,
47 pub max_archive_bytes: u64,
49 pub max_arrays: usize,
51 pub max_array_elements: usize,
53 pub max_uncompressed_array_bytes: u64,
55 pub max_histograms: usize,
57 pub max_phases: usize,
59}
60
61impl Default for ProjectReadLimits {
62 fn default() -> Self {
63 Self {
64 max_manifest_bytes: 16 * 1024 * 1024,
65 max_archive_bytes: 256 * 1024 * 1024,
66 max_arrays: 10_000,
67 max_array_elements: 50_000_000,
68 max_uncompressed_array_bytes: 512 * 1024 * 1024,
69 max_histograms: 10_000,
70 max_phases: 10_000,
71 }
72 }
73}
74
75impl ProjectReadLimits {
76 fn validate(self) -> Result<(), PersistenceError> {
77 if self.max_manifest_bytes == 0
78 || self.max_archive_bytes == 0
79 || self.max_arrays == 0
80 || self.max_array_elements == 0
81 || self.max_uncompressed_array_bytes == 0
82 || self.max_histograms == 0
83 || self.max_phases == 0
84 {
85 return Err(PersistenceError::InvalidLimits);
86 }
87 Ok(())
88 }
89}
90
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub struct ProjectSaveOptions {
94 pub overwrite: bool,
96}
97
98#[derive(Debug)]
100pub enum PersistenceError {
101 InvalidLimits,
103 LimitExceeded {
105 message: String,
107 },
108 InvalidDestination {
110 message: String,
112 },
113 Io(std::io::Error),
115 Json(serde_json::Error),
117 UnsupportedVersion {
119 version: u32,
121 },
122 InvalidArray {
124 message: String,
126 },
127 InvalidArchive {
129 message: String,
131 },
132 InvalidRecord {
134 message: String,
136 },
137 Domain(DomainError),
139}
140
141impl Display for PersistenceError {
142 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::InvalidLimits => formatter.write_str("all project read limits must be positive"),
145 Self::LimitExceeded { message }
146 | Self::InvalidDestination { message }
147 | Self::InvalidArray { message }
148 | Self::InvalidArchive { message }
149 | Self::InvalidRecord { message } => formatter.write_str(message),
150 Self::Io(error) => Display::fmt(error, formatter),
151 Self::Json(error) => Display::fmt(error, formatter),
152 Self::UnsupportedVersion { version } => {
153 write!(formatter, "unsupported native project format {version}")
154 }
155 Self::Domain(error) => Display::fmt(error, formatter),
156 }
157 }
158}
159
160impl Error for PersistenceError {
161 fn source(&self) -> Option<&(dyn Error + 'static)> {
162 match self {
163 Self::Io(error) => Some(error),
164 Self::Json(error) => Some(error),
165 Self::Domain(error) => Some(error),
166 _ => None,
167 }
168 }
169}
170
171impl From<std::io::Error> for PersistenceError {
172 fn from(error: std::io::Error) -> Self {
173 Self::Io(error)
174 }
175}
176
177impl From<serde_json::Error> for PersistenceError {
178 fn from(error: serde_json::Error) -> Self {
179 Self::Json(error)
180 }
181}
182
183#[derive(Debug, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185struct ArchiveRecord {
186 file: String,
187 sha256: String,
188}
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct ProjectManifest {
193 format_version: u32,
194 archive: ArchiveRecord,
195 arrays: BTreeMap<String, ArrayDescriptor>,
196 project: wire::WireProject,
197 #[serde(default)]
198 rietveld_analyses: Option<Vec<rietveld_wire::WireRietveldAnalysis>>,
199}
200
201#[derive(Debug, Deserialize)]
202struct ProjectVersionProbe {
203 format_version: u32,
204}
205
206pub fn save_project(
216 path: impl AsRef<Path>,
217 project: &ProjectRecord,
218 options: ProjectSaveOptions,
219) -> Result<PathBuf, PersistenceError> {
220 project.validate().map_err(PersistenceError::Domain)?;
221 save_project_parts(path.as_ref(), project, Vec::new(), options)
222}
223
224pub fn save_rietveld_project(
231 path: impl AsRef<Path>,
232 state: &RietveldProjectState,
233 options: ProjectSaveOptions,
234) -> Result<PathBuf, PersistenceError> {
235 state
236 .validate()
237 .map_err(|error| PersistenceError::InvalidRecord {
238 message: format!("invalid native Rietveld project state: {error}"),
239 })?;
240 save_project_parts(
241 path.as_ref(),
242 &state.project,
243 rietveld_wire::encode_analyses(state),
244 options,
245 )
246}
247
248fn save_project_parts(
249 path: &Path,
250 project: &ProjectRecord,
251 rietveld_analyses: Vec<rietveld_wire::WireRietveldAnalysis>,
252 options: ProjectSaveOptions,
253) -> Result<PathBuf, PersistenceError> {
254 let destination = absolute_path(path)?;
255 if destination.is_dir() {
256 recover_interrupted_save(&destination)?;
257 }
258 validate_destination(&destination, options)?;
259 let (wire_project, arrays) = wire::encode_project(project)?;
260 let encoded_archive = write_npz(&arrays)?;
261 let descriptors = arrays
262 .iter()
263 .map(|(name, value)| (name.clone(), value.descriptor()))
264 .collect();
265 let manifest = ProjectManifest {
266 format_version: PROJECT_FORMAT_VERSION,
267 archive: ArchiveRecord {
268 file: PROJECT_ARRAYS_NAME.to_owned(),
269 sha256: sha256_hex(&encoded_archive),
270 },
271 arrays: descriptors,
272 project: wire_project,
273 rietveld_analyses: Some(rietveld_analyses),
274 };
275 let mut encoded_manifest = serde_json::to_string_pretty(&manifest)?;
276 encoded_manifest.push('\n');
277
278 let parent = destination
279 .parent()
280 .ok_or_else(|| PersistenceError::InvalidDestination {
281 message: "project destination has no parent directory".to_owned(),
282 })?;
283 fs::create_dir_all(parent)?;
284 let temporary = create_temporary_directory(parent, &destination)?;
285 let write_result: Result<(), PersistenceError> = (|| {
286 write_synced_file(&temporary.join(PROJECT_ARRAYS_NAME), &encoded_archive)?;
287 write_synced_file(
288 &temporary.join(PROJECT_MANIFEST_NAME),
289 encoded_manifest.as_bytes(),
290 )?;
291 fs::create_dir_all(&destination)?;
292 if options.overwrite {
293 backup_owned_file(
294 &destination.join(PROJECT_MANIFEST_NAME),
295 &destination.join(MANIFEST_BACKUP_NAME),
296 )?;
297 backup_owned_file(
298 &destination.join(PROJECT_ARRAYS_NAME),
299 &destination.join(ARRAYS_BACKUP_NAME),
300 )?;
301 }
302 fs::rename(
303 temporary.join(PROJECT_ARRAYS_NAME),
304 destination.join(PROJECT_ARRAYS_NAME),
305 )?;
306 fs::rename(
307 temporary.join(PROJECT_MANIFEST_NAME),
308 destination.join(PROJECT_MANIFEST_NAME),
309 )?;
310 sync_directory(&destination)?;
311 remove_if_exists(&destination.join(MANIFEST_BACKUP_NAME))?;
312 remove_if_exists(&destination.join(ARRAYS_BACKUP_NAME))?;
313 sync_directory(&destination)?;
314 Ok(())
315 })();
316 if write_result.is_err() && destination.is_dir() {
317 let _ = recover_interrupted_save(&destination);
318 }
319 let _ = fs::remove_dir_all(&temporary);
320 write_result?;
321 Ok(destination)
322}
323
324pub fn load_project(
331 path: impl AsRef<Path>,
332 limits: ProjectReadLimits,
333) -> Result<ProjectRecord, PersistenceError> {
334 load_rietveld_project(path, limits).map(|state| state.project)
335}
336
337pub fn load_rietveld_project(
346 path: impl AsRef<Path>,
347 limits: ProjectReadLimits,
348) -> Result<RietveldProjectState, PersistenceError> {
349 limits.validate()?;
350 let source = absolute_path(path.as_ref())?;
351 if source.is_dir() {
352 recover_interrupted_save(&source)?;
353 }
354 let manifest_path = source.join(PROJECT_MANIFEST_NAME);
355 let archive_path = source.join(PROJECT_ARRAYS_NAME);
356 let manifest_bytes = read_bounded_file(
357 &manifest_path,
358 limits.max_manifest_bytes,
359 "project manifest exceeds max_manifest_bytes",
360 )?;
361 let version: ProjectVersionProbe = serde_json::from_slice(&manifest_bytes)?;
362 if !(1..=PROJECT_FORMAT_VERSION).contains(&version.format_version) {
363 return Err(PersistenceError::UnsupportedVersion {
364 version: version.format_version,
365 });
366 }
367 let manifest: ProjectManifest = serde_json::from_slice(&manifest_bytes)?;
368 let rietveld_analyses = match (manifest.format_version, manifest.rietveld_analyses) {
369 (1, None) => Vec::new(),
370 (1, Some(_)) => {
371 return Err(PersistenceError::InvalidRecord {
372 message: "native project format 1 cannot declare Rietveld analyses".to_owned(),
373 });
374 }
375 (_, Some(analyses)) => analyses,
376 (_, None) => {
377 return Err(PersistenceError::InvalidRecord {
378 message: "native project format 2 requires Rietveld analyses".to_owned(),
379 });
380 }
381 };
382 if manifest.archive.file != PROJECT_ARRAYS_NAME {
383 return Err(PersistenceError::InvalidArchive {
384 message: "project archive filename is invalid".to_owned(),
385 });
386 }
387 if manifest.arrays.len() > limits.max_arrays {
388 return Err(PersistenceError::LimitExceeded {
389 message: "project manifest exceeds max_arrays".to_owned(),
390 });
391 }
392 let archive_bytes = read_bounded_file(
393 &archive_path,
394 limits.max_archive_bytes,
395 "project archive exceeds max_archive_bytes",
396 )?;
397 if sha256_hex(&archive_bytes) != manifest.archive.sha256 {
398 return Err(PersistenceError::InvalidArchive {
399 message: "project archive SHA-256 mismatch".to_owned(),
400 });
401 }
402 let arrays = read_npz(&archive_bytes, &manifest.arrays, limits)?;
403 let project = wire::decode_project(manifest.project, arrays, limits)?;
404 rietveld_wire::decode_state(project, rietveld_analyses, limits)
405}
406
407fn read_bounded_file(
408 path: &Path,
409 maximum_bytes: u64,
410 limit_message: &str,
411) -> Result<Vec<u8>, PersistenceError> {
412 let mut bytes = Vec::new();
413 fs::File::open(path)?
414 .take(maximum_bytes.saturating_add(1))
415 .read_to_end(&mut bytes)?;
416 if u64::try_from(bytes.len()).map_or(true, |length| length > maximum_bytes) {
417 return Err(PersistenceError::LimitExceeded {
418 message: limit_message.to_owned(),
419 });
420 }
421 Ok(bytes)
422}
423
424fn absolute_path(path: &Path) -> Result<PathBuf, PersistenceError> {
425 if path.is_absolute() {
426 return Ok(path.to_owned());
427 }
428 Ok(std::env::current_dir()?.join(path))
429}
430
431fn validate_destination(
432 destination: &Path,
433 options: ProjectSaveOptions,
434) -> Result<(), PersistenceError> {
435 if destination.exists() && !destination.is_dir() {
436 return Err(PersistenceError::InvalidDestination {
437 message: format!(
438 "project path exists and is not a directory: {}",
439 destination.display()
440 ),
441 });
442 }
443 if destination.exists() && !options.overwrite {
444 return Err(PersistenceError::InvalidDestination {
445 message: format!(
446 "project directory already exists: {}",
447 destination.display()
448 ),
449 });
450 }
451 Ok(())
452}
453
454fn create_temporary_directory(
455 parent: &Path,
456 destination: &Path,
457) -> Result<PathBuf, PersistenceError> {
458 let stem = destination
459 .file_name()
460 .and_then(|value| value.to_str())
461 .unwrap_or("project");
462 for _ in 0..100 {
463 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
464 let candidate = parent.join(format!(".{stem}-{}-{sequence}.tmp", std::process::id()));
465 match fs::create_dir(&candidate) {
466 Ok(()) => return Ok(candidate),
467 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
468 Err(error) => return Err(PersistenceError::Io(error)),
469 }
470 }
471 Err(PersistenceError::InvalidDestination {
472 message: "could not allocate a temporary project directory".to_owned(),
473 })
474}
475
476fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<(), PersistenceError> {
477 let mut file = fs::File::create(path)?;
478 file.write_all(bytes)?;
479 file.sync_all()?;
480 Ok(())
481}
482
483#[cfg(not(windows))]
484fn sync_directory(path: &Path) -> Result<(), PersistenceError> {
485 fs::File::open(path)?.sync_all()?;
486 Ok(())
487}
488
489#[cfg(windows)]
490fn sync_directory(_path: &Path) -> Result<(), PersistenceError> {
491 Ok(())
495}
496
497fn backup_owned_file(source: &Path, backup: &Path) -> Result<(), PersistenceError> {
498 remove_if_exists(backup)?;
499 if source.exists() {
500 fs::rename(source, backup)?;
501 }
502 Ok(())
503}
504
505fn remove_if_exists(path: &Path) -> Result<(), PersistenceError> {
506 match fs::remove_file(path) {
507 Ok(()) => Ok(()),
508 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
509 Err(error) => Err(PersistenceError::Io(error)),
510 }
511}
512
513fn recover_interrupted_save(directory: &Path) -> Result<(), PersistenceError> {
514 let manifest = directory.join(PROJECT_MANIFEST_NAME);
515 let arrays = directory.join(PROJECT_ARRAYS_NAME);
516 let manifest_backup = directory.join(MANIFEST_BACKUP_NAME);
517 let arrays_backup = directory.join(ARRAYS_BACKUP_NAME);
518 let has_manifest_backup = manifest_backup.exists();
519 let has_arrays_backup = arrays_backup.exists();
520 if !has_manifest_backup && !has_arrays_backup {
521 return Ok(());
522 }
523 if manifest.exists() && arrays.exists() {
524 remove_if_exists(&manifest_backup)?;
525 remove_if_exists(&arrays_backup)?;
526 sync_directory(directory)?;
527 return Ok(());
528 }
529 for (current, backup) in [(&arrays, &arrays_backup), (&manifest, &manifest_backup)] {
530 if backup.exists() {
531 remove_if_exists(current)?;
532 fs::rename(backup, current)?;
533 }
534 }
535 sync_directory(directory)?;
536 Ok(())
537}