Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions dongle-smartcontract/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ pub const MAX_CATEGORY_LEN: usize = 64;
#[allow(dead_code)]
pub const MAX_WEBSITE_LEN: usize = 256;

/// Maximum length for an SPDX license identifier.
pub const MAX_LICENSE_LEN: usize = 64;
/// Maximum length for a project's published security contact.
pub const MAX_SECURITY_CONTACT_LEN: usize = 256;

/// Maximum length for any CID (logo, metadata, comment, evidence).
#[allow(dead_code)]
Expand Down
1 change: 0 additions & 1 deletion dongle-smartcontract/src/fee_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ impl FeeManager {
.persistent()
.set(&StorageKey::FeePaidForProject(project_id), &true);

// Only emit event after successful payment
publish_fee_paid_event(
env,
project_id,
Expand Down
28 changes: 27 additions & 1 deletion dongle-smartcontract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ use crate::types::{
AdminActionEntry, AdminProposal, ClaimRequest, ClaimStatus, Collection, DependencyRef,
DisputeResolutionAction, DisputeStatus, DuplicateDispute, FeeConfig, Project,
ProjectDependency, ProjectRegistrationParams, ProjectReport, ProjectStats, ProjectUpdateParams,
ProposalPayload, Review, TimelockAction, VerificationRecord, VerificationStatus,
ProposalPayload, Review, SecurityContactStatus, TimelockAction, VerificationRecord,
VerificationStatus,
};
use crate::verification_registry::VerificationRegistry;
use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec};
Expand Down Expand Up @@ -137,6 +138,31 @@ impl DongleContract {
ProjectRegistry::update_project(&env, params)
}

pub fn update_security_contact(
env: Env,
project_id: u64,
caller: Address,
contact: Option<String>,
) -> Result<Project, ContractError> {
ProjectRegistry::update_security_contact(&env, project_id, caller, contact)
}

pub fn submit_security_contact_proof(
env: Env,
project_id: u64,
caller: Address,
proof_cid: String,
) -> Result<Project, ContractError> {
ProjectRegistry::submit_security_contact_proof(&env, project_id, caller, proof_cid)
}

pub fn get_security_contact_status(
env: Env,
project_id: u64,
) -> Result<SecurityContactStatus, ContractError> {
ProjectRegistry::get_security_contact_status(&env, project_id)
}

pub fn link_project(
env: Env,
project_id: u64,
Expand Down
86 changes: 85 additions & 1 deletion dongle-smartcontract/src/project_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::storage_keys::{ExtensionKey, StorageKey};
use crate::storage_manager::StorageManager;
use crate::types::{
ClaimRequest, ClaimStatus, Project, ProjectRegistrationParams, ProjectUpdateParams,
VerificationStatus,
SecurityContactStatus, VerificationStatus,
};
use crate::utils::Utils;
use soroban_sdk::{Address, Env, String, Vec};
Expand Down Expand Up @@ -126,6 +126,9 @@ impl ProjectRegistry {
launch_timestamp: params.launch_timestamp,
maintainers: Some(Vec::new(env)),
bounty_url: params.bounty_url.clone(),
security_contact: None,
security_contact_proof_cid: None,
security_contact_verified: false,
};

// Get current owner projects
Expand Down Expand Up @@ -525,6 +528,87 @@ impl ProjectRegistry {
Ok(project)
}

pub fn update_security_contact(
env: &Env,
project_id: u64,
caller: Address,
contact: Option<String>,
) -> Result<Project, ContractError> {
let mut project =
Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?;

caller.require_auth();
let is_owner = project.owner == caller;
let is_maintainer = Self::is_maintainer(env, project_id, &caller);
if !is_owner && !is_maintainer {
return Err(ContractError::Unauthorized);
}

if let Some(value) = &contact {
Utils::validate_security_contact(value)?;
}

if project.security_contact != contact {
project.security_contact = contact;
project.security_contact_proof_cid = None;
project.security_contact_verified = false;
}

project.updated_at = env.ledger().timestamp();
env.storage()
.persistent()
.set(&StorageKey::Project(project_id), &project);
StorageManager::extend_project_ttl(env, project_id);
publish_project_updated_event(env, project_id, project.owner.clone());

Ok(project)
}

pub fn submit_security_contact_proof(
env: &Env,
project_id: u64,
caller: Address,
proof_cid: String,
) -> Result<Project, ContractError> {
let mut project =
Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?;

caller.require_auth();
let is_owner = project.owner == caller;
let is_maintainer = Self::is_maintainer(env, project_id, &caller);
if !is_owner && !is_maintainer {
return Err(ContractError::Unauthorized);
}
if project.security_contact.is_none() {
return Err(ContractError::InvalidProjectData);
}

Utils::validate_metadata_cid(&proof_cid)?;
project.security_contact_proof_cid = Some(proof_cid);
project.security_contact_verified = true;
project.updated_at = env.ledger().timestamp();

env.storage()
.persistent()
.set(&StorageKey::Project(project_id), &project);
StorageManager::extend_project_ttl(env, project_id);
publish_project_updated_event(env, project_id, project.owner.clone());

Ok(project)
}

pub fn get_security_contact_status(
env: &Env,
project_id: u64,
) -> Result<SecurityContactStatus, ContractError> {
let project = Self::get_project(env, project_id).ok_or(ContractError::ProjectNotFound)?;
Ok(SecurityContactStatus {
contact: project.security_contact,
proof_cid: project.security_contact_proof_cid,
verified: project.security_contact_verified,
})
}

pub fn get_project(env: &Env, project_id: u64) -> Option<Project> {
let mut project: Option<Project> = env
.storage()
Expand Down
1 change: 1 addition & 0 deletions dongle-smartcontract/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mod dependencies;
mod maintainers;
mod renewal;
mod review_settings;
mod security_contact;
mod verification_features;

// String validation: names, descriptions, CIDs, categories, URLs
Expand Down
140 changes: 140 additions & 0 deletions dongle-smartcontract/src/tests/security_contact.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use crate::errors::ContractError;
use crate::tests::fixtures::{create_test_project, setup_contract};
use soroban_sdk::{testutils::Address as _, Address, Env, String};

const PROOF_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";

#[test]
fn update_security_contact_sets_unverified_contact() {
let env = Env::default();
let (client, _admin) = setup_contract(&env);
let owner = Address::generate(&env);
let project_id = create_test_project(&client, &owner, "SecurityContactProject");
let contact = String::from_str(&env, "security@example.com");

let project = client.mock_all_auths().update_security_contact(
&project_id,
&owner,
&Some(contact.clone()),
);

assert_eq!(project.security_contact, Some(contact.clone()));
assert_eq!(project.security_contact_proof_cid, None);
assert!(!project.security_contact_verified);

let status = client.get_security_contact_status(&project_id);
assert_eq!(status.contact, Some(contact));
assert_eq!(status.proof_cid, None);
assert!(!status.verified);
}

#[test]
fn submit_security_contact_proof_marks_contact_verified() {
let env = Env::default();
let (client, _admin) = setup_contract(&env);
let owner = Address::generate(&env);
let project_id = create_test_project(&client, &owner, "ProofProject");
let contact = String::from_str(&env, "https://example.com/.well-known/security.txt");
let proof_cid = String::from_str(&env, PROOF_CID);

client
.mock_all_auths()
.update_security_contact(&project_id, &owner, &Some(contact.clone()));
let project =
client
.mock_all_auths()
.submit_security_contact_proof(&project_id, &owner, &proof_cid);

assert_eq!(project.security_contact, Some(contact.clone()));
assert_eq!(project.security_contact_proof_cid, Some(proof_cid.clone()));
assert!(project.security_contact_verified);

let status = client.get_security_contact_status(&project_id);
assert_eq!(status.contact, Some(contact));
assert_eq!(status.proof_cid, Some(proof_cid));
assert!(status.verified);
}

#[test]
fn changing_security_contact_clears_previous_proof() {
let env = Env::default();
let (client, _admin) = setup_contract(&env);
let owner = Address::generate(&env);
let project_id = create_test_project(&client, &owner, "ContactRotationProject");
let first_contact = String::from_str(&env, "security@example.com");
let second_contact = String::from_str(&env, "security-new@example.com");
let proof_cid = String::from_str(&env, PROOF_CID);

client
.mock_all_auths()
.update_security_contact(&project_id, &owner, &Some(first_contact));
client
.mock_all_auths()
.submit_security_contact_proof(&project_id, &owner, &proof_cid);

let project = client.mock_all_auths().update_security_contact(
&project_id,
&owner,
&Some(second_contact.clone()),
);

assert_eq!(project.security_contact, Some(second_contact));
assert_eq!(project.security_contact_proof_cid, None);
assert!(!project.security_contact_verified);
}

#[test]
fn proof_submission_requires_contact_and_valid_cid() {
let env = Env::default();
let (client, _admin) = setup_contract(&env);
let owner = Address::generate(&env);
let project_id = create_test_project(&client, &owner, "ProofValidationProject");
let proof_cid = String::from_str(&env, PROOF_CID);

let result =
client
.mock_all_auths()
.try_submit_security_contact_proof(&project_id, &owner, &proof_cid);
assert_eq!(result, Err(Ok(ContractError::InvalidProjectData)));

client.mock_all_auths().update_security_contact(
&project_id,
&owner,
&Some(String::from_str(&env, "security@example.com")),
);

let bad_cid = String::from_str(&env, "not-a-cid");
let result =
client
.mock_all_auths()
.try_submit_security_contact_proof(&project_id, &owner, &bad_cid);
assert_eq!(result, Err(Ok(ContractError::InvalidMetaCid)));
}

#[test]
fn unauthorized_user_cannot_update_contact_or_submit_proof() {
let env = Env::default();
let (client, _admin) = setup_contract(&env);
let owner = Address::generate(&env);
let stranger = Address::generate(&env);
let project_id = create_test_project(&client, &owner, "ContactAuthProject");
let contact = String::from_str(&env, "security@example.com");
let proof_cid = String::from_str(&env, PROOF_CID);

let result = client.mock_all_auths().try_update_security_contact(
&project_id,
&stranger,
&Some(contact.clone()),
);
assert_eq!(result, Err(Ok(ContractError::Unauthorized)));

client
.mock_all_auths()
.update_security_contact(&project_id, &owner, &Some(contact));
let result = client.mock_all_auths().try_submit_security_contact_proof(
&project_id,
&stranger,
&proof_cid,
);
assert_eq!(result, Err(Ok(ContractError::Unauthorized)));
}
11 changes: 11 additions & 0 deletions dongle-smartcontract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,17 @@ pub struct Project {
pub launch_timestamp: Option<u64>,
pub maintainers: Option<Vec<Address>>,
pub bounty_url: Option<String>,
pub security_contact: Option<String>,
pub security_contact_proof_cid: Option<String>,
pub security_contact_verified: bool,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SecurityContactStatus {
pub contact: Option<String>,
pub proof_cid: Option<String>,
pub verified: bool,
}

#[contracttype]
Expand Down
18 changes: 18 additions & 0 deletions dongle-smartcontract/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,24 @@ impl Utils {
Ok(())
}

pub fn validate_security_contact(contact: &String) -> Result<(), ContractError> {
let len = contact.len();
if len == 0 || len > crate::constants::MAX_SECURITY_CONTACT_LEN as u32 {
return Err(ContractError::InvalidProjectData);
}

let mut buf = [0u8; crate::constants::MAX_SECURITY_CONTACT_LEN];
contact.copy_into_slice(&mut buf[..len as usize]);
let is_whitespace_only = buf[..len as usize]
.iter()
.all(|&b| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r');
if is_whitespace_only {
return Err(ContractError::InvalidProjectData);
}

Ok(())
}

pub fn validate_pagination(_start_id: u64, limit: u32) -> Result<(), ContractError> {
const MAX_LIMIT: u32 = 100;

Expand Down
33 changes: 33 additions & 0 deletions project-metadata.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://dongle.example/schemas/project-metadata.schema.json",
"title": "Dongle Project Metadata",
"type": "object",
"properties": {
"security_contact": {
"type": "object",
"description": "Public security contact details and on-chain ownership proof status.",
"properties": {
"contact": {
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Email address, security.txt URL, web page, or other contact route published by the project."
},
"proof_cid": {
"type": "string",
"minLength": 46,
"maxLength": 128,
"description": "IPFS CID containing evidence that the contact is controlled by the project."
},
"verified": {
"type": "boolean",
"description": "Whether the on-chain project record has accepted a proof CID for the current contact."
}
},
"required": ["contact"],
"additionalProperties": false
}
},
"additionalProperties": true
}
Loading