Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d43c55e7 |
@@ -75,9 +75,6 @@ pub struct UpdateTargetRequest {
|
||||
pub scan_config: Option<TargetScanConfig>,
|
||||
pub compliance_profile: Option<ComplianceProfile>,
|
||||
pub scan_schedule: Option<String>,
|
||||
/// Replace the target's artifacts wholesale (used by the dashboard editor).
|
||||
#[serde(default)]
|
||||
pub artifacts: Option<Vec<ArtifactInput>>,
|
||||
}
|
||||
|
||||
/// One applicable-scan option, serialized for the wizard.
|
||||
@@ -217,13 +214,6 @@ pub async fn update_target(
|
||||
if let Some(ss) = req.scan_schedule {
|
||||
set.insert("scan_schedule", ss);
|
||||
}
|
||||
if let Some(arts) = req.artifacts {
|
||||
let built: Vec<Artifact> = arts.iter().map(ArtifactInput::build).collect();
|
||||
set.insert(
|
||||
"artifacts",
|
||||
to_bson(&built).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
db.onboarded_targets()
|
||||
.update_one(doc! { "_id": oid }, doc! { "$set": set })
|
||||
|
||||
@@ -282,7 +282,7 @@ pub async fn license_summary(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
summaries.sort_by_key(|s| std::cmp::Reverse(s.count));
|
||||
summaries.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: summaries,
|
||||
|
||||
@@ -80,10 +80,7 @@ impl GitOps {
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_name = %repo_name))]
|
||||
pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result<PathBuf, AgentError> {
|
||||
// Names can contain slashes or other path-hostile characters (a target
|
||||
// named after a repo path, say); collapse to one safe directory segment
|
||||
// so the clone path never nests or breaks.
|
||||
let repo_path = self.base_path.join(sanitize_repo_dir(repo_name));
|
||||
let repo_path = self.base_path.join(repo_name);
|
||||
|
||||
if repo_path.exists() {
|
||||
tracing::info!("fetching updates for existing repo");
|
||||
@@ -256,46 +253,3 @@ pub struct DiffFile {
|
||||
pub path: String,
|
||||
pub hunks: String,
|
||||
}
|
||||
|
||||
/// Collapse a repository name into a single filesystem-safe directory segment.
|
||||
/// Names may carry slashes or other path-hostile characters (a target named
|
||||
/// after a repo path, for instance); those would otherwise nest or break the
|
||||
/// clone path, so map anything outside `[A-Za-z0-9._-]` to `_`.
|
||||
fn sanitize_repo_dir(name: &str) -> String {
|
||||
let mapped: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = mapped.trim_matches(|c| c == '.' || c == '_');
|
||||
if trimmed.is_empty() {
|
||||
"repo".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_repo_dir;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_path_hostile_names() {
|
||||
assert_eq!(
|
||||
sanitize_repo_dir("zephyr-example-app"),
|
||||
"zephyr-example-app"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_repo_dir("ChristianRinn/bare_metal_stm32f411xe"),
|
||||
"ChristianRinn_bare_metal_stm32f411xe"
|
||||
);
|
||||
assert_eq!(sanitize_repo_dir("../../etc/passwd"), "etc_passwd");
|
||||
assert_eq!(sanitize_repo_dir("a b:c"), "a_b_c");
|
||||
assert_eq!(sanitize_repo_dir("///"), "repo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,59 +39,6 @@ pub struct ApplicableScansResponse {
|
||||
pub data: ApplicableScansData,
|
||||
}
|
||||
|
||||
/// Validate a target name. The name is used as the clone directory downstream,
|
||||
/// so it must be a single safe segment (no slashes) and free of stray spaces.
|
||||
pub fn validate_target_name(name: &str) -> Option<String> {
|
||||
let n = name.trim();
|
||||
if n.is_empty() {
|
||||
return Some("Enter a name".to_string());
|
||||
}
|
||||
if name != n {
|
||||
return Some("Remove the leading/trailing spaces".to_string());
|
||||
}
|
||||
if n.contains('/') || n.contains('\\') {
|
||||
return Some("No slashes — the name becomes a folder (e.g. stm32f411-blinky)".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Client-side validation of an artifact reference for its kind. Returns an
|
||||
/// error message when the value is obviously wrong for its category, so the
|
||||
/// wizard / editor can flag it up front instead of the scan discovering it.
|
||||
pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option<String> {
|
||||
let s = source_ref;
|
||||
if s.trim().is_empty() {
|
||||
return Some("Cannot be empty".to_string());
|
||||
}
|
||||
if s != s.trim() {
|
||||
return Some("Remove the leading/trailing spaces".to_string());
|
||||
}
|
||||
let no_space = !s.contains(char::is_whitespace);
|
||||
match kind {
|
||||
"git_repo" => {
|
||||
let looks_git = s.starts_with("https://")
|
||||
|| s.starts_with("http://")
|
||||
|| s.starts_with("ssh://")
|
||||
|| s.starts_with("git://")
|
||||
|| (s.contains('@') && s.contains(':'));
|
||||
(!(looks_git && no_space))
|
||||
.then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string())
|
||||
}
|
||||
"live_url" => {
|
||||
let ok = (s.starts_with("https://") || s.starts_with("http://")) && no_space;
|
||||
(!ok).then(|| "Enter an http(s) URL, e.g. https://app.example.com".to_string())
|
||||
}
|
||||
"container_image" => {
|
||||
(!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string())
|
||||
}
|
||||
"source_archive" | "firmware_image" | "mobile_package" | "plc_project" => {
|
||||
(!no_space).then(|| "Enter a path or URL (no spaces)".to_string())
|
||||
}
|
||||
// plaintext_description (and anything unknown): accept free-form text.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// List onboarded targets.
|
||||
#[server]
|
||||
pub async fn fetch_targets() -> Result<TargetsResponse, ServerFnError> {
|
||||
@@ -130,41 +77,6 @@ pub async fn create_target(
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Update a target's name / type / artifacts (dashboard editor).
|
||||
#[server]
|
||||
pub async fn update_target(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
target_type: Option<String>,
|
||||
artifacts: Option<Vec<ArtifactInputDto>>,
|
||||
) -> Result<TargetResponse, ServerFnError> {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(n) = name {
|
||||
body.insert("name".to_string(), serde_json::json!(n));
|
||||
}
|
||||
if let Some(t) = target_type {
|
||||
body.insert("target_type".to_string(), serde_json::json!(t));
|
||||
}
|
||||
if let Some(a) = artifacts {
|
||||
body.insert(
|
||||
"artifacts".to_string(),
|
||||
serde_json::to_value(a).map_err(|e| ServerFnError::new(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
let resp = super::agent_client::agent_request(
|
||||
reqwest::Method::PATCH,
|
||||
&format!("/api/v1/targets/{id}"),
|
||||
)
|
||||
.await?
|
||||
.json(&serde_json::Value::Object(body))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Run kind-based classification on a target.
|
||||
#[server]
|
||||
pub async fn detect_target(id: String) -> Result<TargetResponse, ServerFnError> {
|
||||
|
||||
@@ -2,8 +2,7 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::onboarding::{
|
||||
create_target, detect_target, fetch_applicable_scans, trigger_target_scan,
|
||||
validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
||||
create_target, detect_target, fetch_applicable_scans, trigger_target_scan, ArtifactInputDto,
|
||||
};
|
||||
|
||||
/// (value, label, one-line description) for the 9 target families.
|
||||
@@ -118,15 +117,8 @@ pub fn OnboardingPage() -> Element {
|
||||
let mut scan_msg = use_signal(|| Option::<String>::None);
|
||||
|
||||
let step_now = step();
|
||||
let name_error = validate_target_name(&name());
|
||||
let can_advance_type = name_error.is_none() && !target_type().trim().is_empty();
|
||||
let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty();
|
||||
let has_artifacts = !artifacts().is_empty();
|
||||
// Live validation of the artifact reference being typed (empty = no error yet).
|
||||
let new_source_error = if new_source().is_empty() {
|
||||
None
|
||||
} else {
|
||||
validate_artifact_ref(&new_kind(), &new_source())
|
||||
};
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
@@ -165,11 +157,6 @@ pub fn OnboardingPage() -> Element {
|
||||
value: "{name}",
|
||||
oninput: move |e| name.set(e.value()),
|
||||
}
|
||||
if !name().is_empty() {
|
||||
if let Some(err) = name_error.clone() {
|
||||
div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 4px;", "{err}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 12px;",
|
||||
@@ -226,12 +213,9 @@ pub fn OnboardingPage() -> Element {
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
disabled: new_source().trim().is_empty() || new_source_error.is_some(),
|
||||
onclick: move |_| {
|
||||
let kind = new_kind();
|
||||
if !new_source().trim().is_empty()
|
||||
&& validate_artifact_ref(&kind, &new_source()).is_none()
|
||||
{
|
||||
if !new_source().trim().is_empty() {
|
||||
let branch = if kind == "git_repo" { Some(new_branch()) } else { None };
|
||||
artifacts.write().push(ArtifactInputDto {
|
||||
kind,
|
||||
@@ -245,9 +229,6 @@ pub fn OnboardingPage() -> Element {
|
||||
"+ Add"
|
||||
}
|
||||
}
|
||||
if let Some(err) = new_source_error.clone() {
|
||||
div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 6px;", "{err}" }
|
||||
}
|
||||
|
||||
div { style: "margin-top: 16px;",
|
||||
if has_artifacts {
|
||||
|
||||
@@ -12,35 +12,9 @@ use dioxus_free_icons::Icon;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
use crate::infrastructure::onboarding::{
|
||||
delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan, update_target,
|
||||
validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
||||
delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan,
|
||||
};
|
||||
|
||||
/// The nine target families (value, label) for the edit form's type selector.
|
||||
const TARGET_TYPES: &[(&str, &str)] = &[
|
||||
("web_app", "Web Application"),
|
||||
("backend_service", "Backend / API"),
|
||||
("desktop_app", "Desktop App"),
|
||||
("android_app", "Android App"),
|
||||
("ios_app", "iOS App"),
|
||||
("firmware_bare_metal", "Firmware — bare metal"),
|
||||
("firmware_rtos", "Firmware — RTOS"),
|
||||
("embedded_linux_yocto", "Embedded Linux / Yocto"),
|
||||
("plc_sps", "PLC / SPS"),
|
||||
];
|
||||
|
||||
/// The artifact kinds (value, label) for the edit form.
|
||||
const ARTIFACT_KINDS: &[(&str, &str)] = &[
|
||||
("git_repo", "Git repository"),
|
||||
("source_archive", "Source archive (zip)"),
|
||||
("firmware_image", "Firmware image"),
|
||||
("mobile_package", "Mobile package (APK/IPA)"),
|
||||
("container_image", "Container image"),
|
||||
("live_url", "Live URL"),
|
||||
("plc_project", "PLC project"),
|
||||
("plaintext_description", "Description (text)"),
|
||||
];
|
||||
|
||||
/// Prettify a snake_case target-type value into a human label.
|
||||
fn pretty_type(v: &str) -> String {
|
||||
match v {
|
||||
@@ -132,17 +106,6 @@ pub fn TargetsPage() -> Element {
|
||||
let mut expanded_ids = use_signal(Vec::<String>::new);
|
||||
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None);
|
||||
|
||||
// Edit-target modal state.
|
||||
let mut edit_id = use_signal(|| Option::<String>::None);
|
||||
let mut edit_name = use_signal(String::new);
|
||||
let mut edit_type = use_signal(String::new);
|
||||
let mut edit_arts = use_signal(Vec::<ArtifactInputDto>::new);
|
||||
let mut edit_saving = use_signal(|| false);
|
||||
// In-modal "add artifact" mini-form.
|
||||
let mut e_kind = use_signal(|| "git_repo".to_string());
|
||||
let mut e_source = use_signal(String::new);
|
||||
let mut e_branch = use_signal(|| "main".to_string());
|
||||
|
||||
let mut targets = use_resource(move || async move { fetch_targets().await.ok() });
|
||||
|
||||
rsx! {
|
||||
@@ -200,145 +163,6 @@ pub fn TargetsPage() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit target ──
|
||||
if let Some(eid) = edit_id() {
|
||||
{
|
||||
let name_err = validate_target_name(&edit_name());
|
||||
let e_source_err = if e_source().is_empty() {
|
||||
None
|
||||
} else {
|
||||
validate_artifact_ref(&e_kind(), &e_source())
|
||||
};
|
||||
rsx! {
|
||||
div { class: "modal-overlay",
|
||||
div { class: "modal-dialog",
|
||||
h3 { "Edit target" }
|
||||
div { class: "form-group",
|
||||
label { "Name" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{edit_name}",
|
||||
oninput: move |e| edit_name.set(e.value()),
|
||||
}
|
||||
if !edit_name().is_empty() {
|
||||
if let Some(err) = name_err.clone() {
|
||||
div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Type" }
|
||||
select {
|
||||
value: "{edit_type}",
|
||||
oninput: move |e| edit_type.set(e.value()),
|
||||
for (v, l) in TARGET_TYPES.iter().copied() {
|
||||
option { value: "{v}", "{l}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
label { style: "font-weight: 600;", "Artifacts" }
|
||||
for (i, a) in edit_arts().iter().enumerate() {
|
||||
div { style: "display: flex; justify-content: space-between; align-items: center; padding: 4px 0;",
|
||||
span { style: "font-size: 0.9em;",
|
||||
span { style: "opacity: 0.7;", "{a.kind}: " }
|
||||
span { style: "font-family: monospace;", "{a.source_ref}" }
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-ghost-danger btn-sm",
|
||||
onclick: move |_| { edit_arts.write().remove(i); },
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "display: flex; gap: 8px; align-items: flex-end; margin-top: 8px;",
|
||||
div { class: "form-group", style: "margin: 0;",
|
||||
label { "Kind" }
|
||||
select {
|
||||
value: "{e_kind}",
|
||||
oninput: move |e| e_kind.set(e.value()),
|
||||
for (v, l) in ARTIFACT_KINDS.iter().copied() {
|
||||
option { value: "{v}", "{l}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "form-group", style: "margin: 0; flex: 1;",
|
||||
label { "Reference" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{e_source}",
|
||||
oninput: move |e| e_source.set(e.value()),
|
||||
}
|
||||
}
|
||||
if e_kind() == "git_repo" {
|
||||
div { class: "form-group", style: "margin: 0;",
|
||||
label { "Branch" }
|
||||
input {
|
||||
r#type: "text",
|
||||
value: "{e_branch}",
|
||||
oninput: move |e| e_branch.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
disabled: e_source().trim().is_empty() || e_source_err.is_some(),
|
||||
onclick: move |_| {
|
||||
let kind = e_kind();
|
||||
if !e_source().trim().is_empty()
|
||||
&& validate_artifact_ref(&kind, &e_source()).is_none()
|
||||
{
|
||||
let branch = if kind == "git_repo" { Some(e_branch()) } else { None };
|
||||
edit_arts.write().push(ArtifactInputDto {
|
||||
kind,
|
||||
source_ref: e_source(),
|
||||
branch,
|
||||
plc_format: None,
|
||||
});
|
||||
e_source.set(String::new());
|
||||
}
|
||||
},
|
||||
"+ Add"
|
||||
}
|
||||
}
|
||||
if let Some(err) = e_source_err.clone() {
|
||||
div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" }
|
||||
}
|
||||
div { class: "modal-actions",
|
||||
button {
|
||||
class: "btn btn-secondary",
|
||||
onclick: move |_| edit_id.set(None),
|
||||
"Cancel"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: edit_saving() || name_err.is_some(),
|
||||
onclick: move |_| {
|
||||
let id = eid.clone();
|
||||
let nm = edit_name();
|
||||
let tt = edit_type();
|
||||
let arts = edit_arts();
|
||||
edit_saving.set(true);
|
||||
spawn(async move {
|
||||
match update_target(id, Some(nm), Some(tt), Some(arts)).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Target updated");
|
||||
targets.restart();
|
||||
edit_id.set(None);
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
edit_saving.set(false);
|
||||
});
|
||||
},
|
||||
if edit_saving() { "Saving..." } else { "Save" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let targets_snapshot = targets.read().clone();
|
||||
match &targets_snapshot {
|
||||
@@ -399,12 +223,8 @@ pub fn TargetsPage() -> Element {
|
||||
let id_scan = id.clone();
|
||||
let id_exp = id.clone();
|
||||
let id_del = id.clone();
|
||||
let id_edit = id.clone();
|
||||
let name_del = name.clone();
|
||||
let name_edit = name.clone();
|
||||
let ttype_raw = str_at(&t, "target_type").to_string();
|
||||
let artifacts_detail = artifacts.clone();
|
||||
let artifacts_edit = artifacts.clone();
|
||||
rsx! {
|
||||
tr {
|
||||
td { strong { "{name}" } }
|
||||
@@ -433,31 +253,6 @@ pub fn TargetsPage() -> Element {
|
||||
},
|
||||
Icon { icon: BsInfoCircle, width: 16, height: 16 }
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
title: "Edit target",
|
||||
onclick: move |_| {
|
||||
edit_name.set(name_edit.clone());
|
||||
edit_type.set(ttype_raw.clone());
|
||||
let arts: Vec<ArtifactInputDto> = artifacts_edit
|
||||
.iter()
|
||||
.map(|a| ArtifactInputDto {
|
||||
kind: str_at(a, "kind").to_string(),
|
||||
source_ref: str_at(a, "source_ref").to_string(),
|
||||
branch: a
|
||||
.get("git")
|
||||
.and_then(|g| g.get("default_branch"))
|
||||
.and_then(|b| b.as_str())
|
||||
.map(String::from),
|
||||
plc_format: None,
|
||||
})
|
||||
.collect();
|
||||
edit_arts.set(arts);
|
||||
e_source.set(String::new());
|
||||
edit_id.set(Some(id_edit.clone()));
|
||||
},
|
||||
Icon { icon: BsPencil, width: 16, height: 16 }
|
||||
}
|
||||
button {
|
||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||
title: "Run scan",
|
||||
|
||||
Reference in New Issue
Block a user