rafia_stpa/stpa/
constraints.rs

1// *****************************************************************************
2// Copyright (c) 2025 Codethink
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License, v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is
12// available at https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16use std::{
17    collections::HashSet,
18    ffi::OsStr,
19    fmt::{self, Display},
20    fs::{self, remove_file, File, OpenOptions},
21    io::{self, Write},
22    option::Option,
23    path::Path,
24    str::FromStr,
25    sync::Arc,
26};
27
28#[cfg(feature = "clap")]
29use clap::ValueEnum;
30use csv;
31#[cfg(feature = "pyo3")]
32use pyo3::prelude::*;
33use serde::{Deserialize, Serialize};
34use serde_yaml;
35use strum::EnumIter;
36use thiserror::Error;
37
38#[cfg(feature = "pyo3")]
39use crate::trudag::{
40    create_link, create_node, find_root, remove_node, rooted_relative_path, set_node,
41    set_node_links, RootError, TrudagError,
42};
43use crate::{
44    define_default_link_type_impls,
45    stpa::{LoadError, CYCLE_DETECTED_MSG},
46};
47
48use super::{CausalScenarioLink, ConstraintLink, HazardLink, StpaData, StpaProject};
49
50#[derive(Debug, Clone)]
51#[cfg_attr(feature = "clap", derive(ValueEnum))]
52pub enum RefType {
53    File,
54    StpaDescribe,
55}
56
57impl Display for RefType {
58    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59        match self {
60            RefType::File => write!(f, "file"),
61            RefType::StpaDescribe => write!(f, "stpa-describe"),
62        }
63    }
64}
65
66impl FromStr for RefType {
67    type Err = ();
68
69    fn from_str(value: &str) -> Result<Self, Self::Err> {
70        match value.to_lowercase().as_str() {
71            "file" => Ok(RefType::File),
72            "stpa-describe" => Ok(RefType::StpaDescribe),
73            _ => Err(()),
74        }
75    }
76}
77
78#[derive(Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Serialize, Deserialize)]
79pub struct UcaLink(Arc<str>);
80
81#[derive(Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Serialize, Deserialize)]
82pub struct CsLink(Arc<str>);
83
84#[derive(Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Debug, Serialize, Deserialize)]
85pub struct TsfLink(Arc<str>);
86
87define_default_link_type_impls!(TsfLink);
88define_default_link_type_impls!(UcaLink);
89define_default_link_type_impls!(CsLink);
90
91#[derive(Debug, Serialize, Deserialize)]
92pub struct ConstraintCsv {
93    #[serde(rename = "Constraint Id")]
94    pub id: String,
95    #[serde(rename = "Description")]
96    pub description: String,
97    #[serde(rename = "Constraint Type")]
98    pub constraint_type: String,
99    #[serde(rename = "Link to Constraint(s)")]
100    pub link_to_contraints: String,
101    #[serde(rename = "Link to Hazard(s)")]
102    pub link_to_hazards: String,
103    #[serde(rename = "Links to UCA")]
104    pub link_to_uca: String,
105    #[serde(rename = "Links to CS")]
106    pub link_to_cs: String,
107    #[serde(rename = "Links to TSF")]
108    pub link_to_tsf: String,
109}
110
111// This can represent both the file and the stpa reference
112// We could just have it as a HashMap<String, String> so this
113// struct enforces that only valid keys can from ether can be used
114// but it does not stop some keys from one and other keys from the other
115// we hope to only support the stpa reference in the future so instead
116// of a more complex enum in the short term, the optional fields are used.
117// The field usage is as follows:
118// * trudag_type is always required and should be `file` or `stpa`.
119// * path must be used for file.
120// * sha can be used for file.
121// * project and item must be used for item.
122// One we only need to support stpa/describe references we will remove the
123// options, and have cocreate fields
124#[derive(Debug, Serialize, Deserialize)]
125struct TrudagRef {
126    #[serde(rename = "type")]
127    trudag_type: String,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    path: Option<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    project: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    sha: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    item: Option<String>,
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139struct TrudagYaml {
140    references: Vec<TrudagRef>,
141    level: String,
142    normative: bool,
143}
144
145fn to_trudag_id(input: &str) -> String {
146    input.replace(".", "_").replace("-", "_").to_uppercase()
147}
148
149#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
150pub struct Constraint {
151    pub id: String,
152    pub description: String,
153    pub constraint_type: ConstraintType,
154    pub link_to_constraints: Vec<ConstraintLink>,
155    pub link_to_hazards: Vec<HazardLink>,
156    pub link_to_uca: Vec<UcaLink>,
157    pub link_to_cs: Vec<CausalScenarioLink>,
158    pub link_to_tsf: Vec<String>,
159}
160
161impl Constraint {
162    pub fn describe(
163        &self,
164        stpa_data: &StpaData,
165        currently_expanding: &mut Vec<String>,
166    ) -> Result<serde_json::Value, String> {
167        // check we aren't currently expanding this constraint
168        if currently_expanding.contains(&self.id) {
169            return Ok(serde_json::json!({
170                "constraint": {
171                    "id": self.id,
172                    "message": CYCLE_DETECTED_MSG,
173                }
174            }));
175        } else {
176            currently_expanding.push(self.id.clone());
177        }
178
179        let mut obj = serde_json::json!({"constraint": self});
180
181        let constraints = self
182            .link_to_constraints
183            .iter()
184            .map(|constraint| {
185                stpa_data.describe_inner(constraint.0.as_ref(), currently_expanding).map_err(|e| {
186                    format!(
187                        "Failed while resolving link to constraint id {constraint:?} of constraint {:?}: {e}",
188                        self.id
189                    )
190                })
191            })
192            .collect::<Result<Vec<_>, _>>()?;
193        obj["constraint"]["link_to_constraints"] = serde_json::json!(constraints);
194
195        let hazards = self
196            .link_to_hazards
197            .iter()
198            .map(|hazard| {
199                stpa_data
200                    .describe_inner(hazard.0.as_ref(), currently_expanding)
201                    .map_err(|e| {
202                        format!(
203                            "Failed while resolving hazard id {hazard:?} of constraint {:?}: {e}",
204                            self.id
205                        )
206                    })
207            })
208            .collect::<Result<Vec<_>, _>>()?;
209        obj["constraint"]["link_to_hazards"] = serde_json::json!(hazards);
210
211        let ucas = self
212            .link_to_uca
213            .iter()
214            .map(|uca| {
215                stpa_data
216                    .describe_inner(uca.0.as_ref(), currently_expanding)
217                    .map_err(|e| {
218                        format!(
219                            "Failed while resolving UCA id {uca:?} of constraint {:?}: {e}",
220                            self.id
221                        )
222                    })
223            })
224            .collect::<Result<Vec<_>, _>>()?;
225        obj["constraint"]["link_to_uca"] = serde_json::json!(ucas);
226
227        let cs = self
228            .link_to_cs
229            .iter()
230            .map(|cs| {
231                stpa_data
232                    .describe_inner(cs.0.as_ref(), currently_expanding)
233                    .map_err(|e| {
234                        format!(
235                            "Failed while resolving CS id {cs:?} of constraint {:?}: {e}",
236                            self.id
237                        )
238                    })
239            })
240            .collect::<Result<Vec<_>, _>>()?;
241        obj["constraint"]["link_to_cs"] = serde_json::json!(cs);
242
243        let popped = currently_expanding.pop();
244        debug_assert_eq!(popped.as_ref(), Some(&self.id));
245        Ok(obj)
246    }
247
248    /// Get the file name that this Constraint will be saved in
249    pub fn trudag_filename(&self, group: &str) -> String {
250        format!("{}.md", self.trudag_name(group))
251    }
252
253    /// Get the trudag nane of this constraint when exported to a trudag file
254    ///
255    /// For a file in a TSF graph `some/folder/GROUP-ID.md` the name becomes `GROUP-ID`
256    pub fn trudag_name(&self, group: &str) -> String {
257        format!("{group}-{}", self.trudag_id())
258    }
259
260    /// Get the trudag id of this constraint when exported to a trudag file
261    ///
262    /// For a file in a TSF graph `some/folder/GROUP-ID.md`
263    pub fn trudag_id(&self) -> String {
264        to_trudag_id(&self.id)
265    }
266
267    #[cfg(feature = "pyo3")]
268    /// Create the contents of a trudag element based on this constraint
269    ///
270    /// This function can create items with ether basic file refrernces
271    /// or describe based references.
272    fn to_trudag(
273        &self,
274        root: &Path,
275        stpa_project: &StpaProject,
276        level: String,
277        reftype: &RefType,
278    ) -> String {
279        let stpa_project_file =
280            rooted_relative_path(root, stpa_project.project_file.as_ref().unwrap())
281                .unwrap()
282                .to_string_lossy()
283                .to_string();
284        let constraint_path = rooted_relative_path(
285            root,
286            &stpa_project
287                .root
288                .as_ref()
289                .unwrap()
290                .join(stpa_project.constraints.as_ref().unwrap()),
291        )
292        .unwrap()
293        .to_string_lossy()
294        .to_string();
295        let references = match reftype {
296            RefType::File => {
297                let mut full_refs = vec![TrudagRef {
298                    path: Some(constraint_path),
299                    trudag_type: "file".to_string(),
300                    sha: None,
301                    item: None,
302                    project: None,
303                }];
304
305                if !self.link_to_hazards.is_empty() {
306                    let hazard_path = rooted_relative_path(
307                        root,
308                        &stpa_project
309                            .root
310                            .as_ref()
311                            .unwrap()
312                            .join(stpa_project.hazards.as_ref().unwrap()),
313                    )
314                    .unwrap()
315                    .to_string_lossy()
316                    .to_string();
317                    full_refs.push(TrudagRef {
318                        trudag_type: "file".to_string(),
319                        path: Some(hazard_path),
320                        sha: None,
321                        item: None,
322                        project: None,
323                    })
324                }
325                if !self.link_to_uca.is_empty() {
326                    let uca_path = rooted_relative_path(
327                        root,
328                        &stpa_project
329                            .root
330                            .as_ref()
331                            .unwrap()
332                            .join(stpa_project.uca.as_ref().unwrap()),
333                    )
334                    .unwrap()
335                    .to_string_lossy()
336                    .to_string();
337                    full_refs.push(TrudagRef {
338                        trudag_type: "file".to_string(),
339                        path: Some(uca_path),
340                        sha: None,
341                        item: None,
342                        project: None,
343                    })
344                }
345                if !self.link_to_cs.is_empty() {
346                    let scenario_path = rooted_relative_path(
347                        root,
348                        &stpa_project
349                            .root
350                            .as_ref()
351                            .unwrap()
352                            .join(stpa_project.scenarios.as_ref().unwrap()),
353                    )
354                    .unwrap()
355                    .to_string_lossy()
356                    .to_string();
357                    full_refs.push(TrudagRef {
358                        trudag_type: "file".to_string(),
359                        path: Some(scenario_path),
360                        sha: None,
361                        item: None,
362                        project: None,
363                    })
364                }
365                full_refs
366            }
367            RefType::StpaDescribe => vec![TrudagRef {
368                project: Some(stpa_project_file),
369                path: None,
370                trudag_type: "stpa-describe".to_string(),
371                sha: None,
372                item: Some(self.id.to_string()),
373            }],
374        };
375        let meta = TrudagYaml {
376            references,
377            level,
378            normative: true,
379        };
380
381        let meta = serde_yaml::to_string(&meta).unwrap();
382        let mut out = String::new();
383        out.push_str("---\n");
384        out.push_str(&meta);
385
386        out.push_str("---\n\n");
387        out.push_str(&self.description);
388        out.push('\n');
389
390        out
391    }
392}
393
394impl From<ConstraintCsv> for Constraint {
395    fn from(csv: ConstraintCsv) -> Self {
396        Constraint {
397            id: csv.id,
398            description: csv.description,
399            constraint_type: ConstraintType::from(csv.constraint_type.as_str()),
400            link_to_constraints: csv
401                .link_to_contraints
402                .split(",")
403                .filter(|e| !e.is_empty())
404                .map(|s| ConstraintLink::from(s.trim()))
405                .collect(),
406            link_to_hazards: csv
407                .link_to_hazards
408                .split(",")
409                .filter(|e| !e.is_empty())
410                .map(|s| HazardLink::from(s.trim()))
411                .collect(),
412            link_to_uca: csv
413                .link_to_uca
414                .split(",")
415                .filter(|e| !e.is_empty())
416                .map(|s| UcaLink::from(s.trim()))
417                .collect(),
418            link_to_cs: csv
419                .link_to_cs
420                .split(",")
421                .filter(|e| !e.is_empty())
422                .map(|s| CausalScenarioLink::from(s.trim()))
423                .collect(),
424            link_to_tsf: csv
425                .link_to_tsf
426                .split(",")
427                .filter(|e| !e.is_empty())
428                .map(|s| s.trim().to_owned())
429                .collect(),
430        }
431    }
432}
433
434#[cfg(feature = "pyo3")]
435#[derive(Error, Debug)]
436pub enum ToTrudagError {
437    #[error("Could not find root")]
438    Root(#[from] RootError),
439    #[error("Project does not exist")]
440    NoProject(io::Error, String),
441    #[error("Could not create a link")]
442    Create(TrudagError, String),
443    #[error("Could not link a item")]
444    Link(TrudagError, String, String),
445    #[error("Could not remove a item")]
446    Remove(TrudagError, String),
447    #[error("Could not clear a item")]
448    Clear(TrudagError, String),
449    #[error("Could not save")]
450    Save(#[from] std::io::Error),
451    #[error("Python trudag error")]
452    ErrorPython(#[from] PyErr),
453}
454
455#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Default)]
456pub struct Constraints {
457    pub constraints: Vec<Constraint>,
458}
459
460impl Constraints {
461    pub fn from_yaml(text: &str) -> Result<Constraints, LoadError> {
462        serde_yaml::from_str::<Constraints>(text).map_err(LoadError::Yaml)
463    }
464    pub fn to_yaml(&self) -> Result<String, LoadError> {
465        serde_yaml::to_string(&self).map_err(LoadError::Yaml)
466    }
467    pub fn from_csv(filename: &Path) -> Result<Constraints, LoadError> {
468        let mut constraints = vec![];
469        let file = File::open(filename)?;
470        for constraint in csv::ReaderBuilder::new()
471            .from_reader(file)
472            .deserialize::<ConstraintCsv>()
473        {
474            constraints.push(constraint?.into());
475        }
476        Ok(Constraints { constraints })
477    }
478    pub fn from_file(filename: &Path) -> Result<Constraints, LoadError> {
479        match filename.extension().and_then(OsStr::to_str) {
480            Some("csv") => Self::from_csv(filename),
481            Some("yml") | Some("yaml") => {
482                let text = fs::read_to_string(filename)?;
483                Self::from_yaml(&text)
484            }
485            _ => Err(LoadError::NotSupportedFormat),
486        }
487    }
488    #[cfg(feature = "pyo3")]
489    pub fn to_trudag(
490        &self,
491        folder: &Path,
492        stpa_project: &StpaProject,
493        group: &str,
494        reftype: RefType,
495        set_links: bool,
496    ) -> Result<(), ToTrudagError> {
497        let folder = folder.canonicalize().map_err(|e| {
498            ToTrudagError::NoProject(
499                e,
500                folder
501                    .to_str()
502                    .map(|str| str.to_owned())
503                    .unwrap_or(format!("{folder:?}")),
504            )
505        })?;
506
507        let trudag_root = find_root(&folder)?;
508
509        let paths = fs::read_dir(&folder).unwrap();
510
511        let trudag_graph = Python::with_gil(|py| {
512            let trudag_dotstop_graph = py.import("trudag.dotstop.core.graph")?;
513            let from_dir = trudag_dotstop_graph.getattr("build_trustable_graph")?;
514            let trudag_graph = from_dir.call(
515                (trudag_root.join(".dotstop.dot"), trudag_root.clone()),
516                None,
517            )?;
518            let trudag_graph = trudag_graph.unbind();
519            PyResult::Ok(trudag_graph)
520        })?;
521
522        // Remove any items we no longer use to avoid anything deleted in the stpa from
523        // being left in the trustable report
524        let mut reused_constraints = HashSet::new();
525        for path in paths {
526            let path = path.unwrap();
527            let path = path.path();
528            if path.is_file() {
529                let constraint_file_name = path.file_name().unwrap().to_string_lossy();
530                let constraint = path.file_stem().unwrap().to_string_lossy();
531                if let Some(existing) = self.constraints.iter().find(|cmp_constraint| {
532                    cmp_constraint.trudag_filename(group).as_str() == constraint_file_name
533                }) {
534                    reused_constraints.insert(existing.id.to_owned());
535                } else {
536                    remove_node(&trudag_root, &trudag_graph, &constraint)
537                        .map_err(|e| ToTrudagError::Remove(e, constraint.to_string()))?;
538
539                    remove_file(&path)?;
540                    println!("Removed {path:?}");
541                }
542            }
543        }
544
545        for (index, constraint) in self.constraints.iter().enumerate() {
546            if !reused_constraints.contains(&constraint.id) {
547                create_node(
548                    &trudag_root,
549                    &trudag_graph,
550                    group,
551                    None,
552                    &folder,
553                    constraint,
554                )
555                .map_err(|e| ToTrudagError::Create(e, format!("{constraint:?}")))?;
556            }
557            let filename = folder.join(constraint.trudag_filename(group));
558            let trudag = constraint.to_trudag(
559                &trudag_root,
560                stpa_project,
561                format!("1.{}", index + 1),
562                &reftype,
563            );
564            let mut f = OpenOptions::new()
565                .write(true)
566                .truncate(true)
567                .open(filename)?;
568            f.write_all(trudag.as_bytes())?;
569            println!("Created {}", constraint.id)
570        }
571
572        let trudag_graph = Python::with_gil(|py| {
573            let trudag_dotstop_graph = py.import("trudag.dotstop.core.graph")?;
574            let from_dir = trudag_dotstop_graph.getattr("build_trustable_graph")?;
575            let trudag_graph = from_dir.call(
576                (trudag_root.join(".dotstop.dot"), trudag_root.clone()),
577                None,
578            )?;
579            let trudag_graph = trudag_graph.unbind();
580            PyResult::Ok(trudag_graph)
581        })?;
582
583        // We create and then link in two loops as we need to ensure that elements are created,
584        // before we link to them.
585        for constraint in &self.constraints {
586            for tsf in &constraint.link_to_tsf {
587                create_link(&trudag_root, &trudag_graph, group, constraint, tsf).map_err(|e| {
588                    ToTrudagError::Link(e, format!("{constraint:?}"), tsf.to_string())
589                })?;
590            }
591            for constraint_link in &constraint.link_to_constraints {
592                create_link(
593                    &trudag_root,
594                    &trudag_graph,
595                    group,
596                    constraint,
597                    &format!("{group}-{}", to_trudag_id(&constraint_link.0)),
598                )
599                .map_err(|e| {
600                    ToTrudagError::Link(e, format!("{constraint:?}"), constraint_link.0.to_string())
601                })?;
602            }
603            println!("Linked {}", constraint.id)
604        }
605        // Todo: Does this need to be done after the links or could it be done in the first loop
606        for constraint in &self.constraints {
607            set_node(&trudag_root, &trudag_graph, group, constraint)
608                .map_err(|e| ToTrudagError::Clear(e, format!("{constraint:?}")))?;
609            if set_links {
610                set_node_links(&trudag_root, &trudag_graph, group, constraint)
611                    .map_err(|e| ToTrudagError::Clear(e, format!("{constraint:?}")))?;
612            }
613            println!("Set {}", constraint.id)
614        }
615        Ok(())
616    }
617    pub fn find(&self, id: &str) -> Option<&Constraint> {
618        self.constraints
619            .iter()
620            .find(|constraint| constraint.id == id)
621    }
622}
623
624#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, EnumIter)]
625pub enum ConstraintType {
626    SystemLevelConstraint,
627    ControllerFunctionalConstraint,
628    CausalScenarioConstraint,
629    Missing,
630}
631
632impl fmt::Display for ConstraintType {
633    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
634        match self {
635            ConstraintType::SystemLevelConstraint => {
636                write!(f, "System Level Constraint")
637            }
638            ConstraintType::ControllerFunctionalConstraint => {
639                write!(f, "Constroller Functional Constartint")
640            }
641            ConstraintType::CausalScenarioConstraint => {
642                write!(f, "Causal Scenario Constraint")
643            }
644            ConstraintType::Missing => {
645                write!(f, "Constraint not definded or missing")
646            }
647        }
648    }
649}
650
651impl From<&str> for ConstraintType {
652    fn from(raw: &str) -> Self {
653        match raw.trim() {
654            "SLC" => ConstraintType::SystemLevelConstraint,
655            "CFC" => ConstraintType::ControllerFunctionalConstraint,
656            "CSC" => ConstraintType::CausalScenarioConstraint,
657            _ => ConstraintType::Missing,
658        }
659    }
660}