Skip to main content

aex_conformance/
lib.rs

1//! Conformance test suite for AEX v2 (ADR-0048).
2//!
3//! Each test is an async function that returns
4//! `Result<(), ConformanceFailure>`. The library exposes them as a
5//! flat list so the binary can iterate, capture results, and print a
6//! summary; downstream code (CI integrations, dashboards) can also
7//! import the list and slice it by category.
8//!
9//! # Scope at v2.0 GA
10//!
11//! Offline checks of the local AEX stack: wire-v2 round-trip, JWS
12//! algorithm whitelist enforcement, SSRF resistance of `safe_http`,
13//! clock-skew handling, capability registry stability, DID URI
14//! parser strictness, cross-version isolation between v1 and v2.
15//! Network-aware checks against a remote control plane URL stage as
16//! v2.1.
17
18use std::fmt;
19use std::future::Future;
20use std::pin::Pin;
21use std::time::Duration;
22
23use aex_core::{
24    capability::Capability, wire, wire_v2, AgentId, CapabilitySet, IdScheme, IdentityProvider,
25};
26use aex_identity::DidKeyProvider;
27use aex_jws::{sign_ed25519, verify, JwsError, VerifierKey};
28use aex_net::is_forbidden_ip;
29use ed25519_dalek::SigningKey;
30
31/// Outcome of running a single conformance test.
32#[derive(Debug, Clone)]
33pub struct ConformanceResult {
34    /// Stable identifier (kebab-case). Becomes part of the JSON
35    /// report and the badge URL hash.
36    pub id: &'static str,
37    /// Category for output grouping. Free-text; the binary groups by
38    /// exact-string equality.
39    pub category: &'static str,
40    /// Pass/fail outcome.
41    pub outcome: Outcome,
42}
43
44/// Pass / fail discriminant.
45#[derive(Debug, Clone)]
46pub enum Outcome {
47    /// All assertions held.
48    Pass,
49    /// One or more assertions failed; the message is operator-readable.
50    Fail(String),
51}
52
53impl Outcome {
54    /// True iff `self` is [`Outcome::Pass`].
55    pub fn is_pass(&self) -> bool {
56        matches!(self, Outcome::Pass)
57    }
58}
59
60impl fmt::Display for Outcome {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Outcome::Pass => write!(f, "PASS"),
64            Outcome::Fail(msg) => write!(f, "FAIL: {}", msg),
65        }
66    }
67}
68
69/// Future type produced by every conformance test runner.
70pub type TestFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
71
72/// Runner function pointer type. Kept as a type alias for clippy.
73pub type TestRunner = fn() -> TestFuture;
74
75/// A single conformance test registration.
76pub struct ConformanceTest {
77    /// Stable identifier (kebab-case). Becomes part of the JSON report.
78    pub id: &'static str,
79    /// Display category.
80    pub category: &'static str,
81    /// Runner function — async, returns `Ok(())` on pass.
82    pub run: TestRunner,
83}
84
85/// Build the full v2.0 GA conformance test list.
86///
87/// Order matters only for output grouping. The set is stable: removing
88/// or renaming a test breaks any deployment that pinned its
89/// `conformance_hash` in a release artefact.
90pub fn all_tests() -> Vec<ConformanceTest> {
91    vec![
92        ConformanceTest {
93            id: "wire-v2-roundtrip",
94            category: "wire",
95            run: || Box::pin(test_wire_v2_roundtrip()),
96        },
97        ConformanceTest {
98            id: "wire-v1-still-functional",
99            category: "wire",
100            run: || Box::pin(test_wire_v1_still_functional()),
101        },
102        ConformanceTest {
103            id: "cross-version-isolation",
104            category: "wire",
105            run: || Box::pin(test_cross_version_isolation()),
106        },
107        ConformanceTest {
108            id: "jws-algorithm-whitelist",
109            category: "jws",
110            run: || Box::pin(test_jws_algorithm_whitelist()),
111        },
112        ConformanceTest {
113            id: "jws-alg-none-rejected",
114            category: "jws",
115            run: || Box::pin(test_jws_alg_none_rejected()),
116        },
117        ConformanceTest {
118            id: "jws-alg-hs256-rejected",
119            category: "jws",
120            run: || Box::pin(test_jws_alg_hs256_rejected()),
121        },
122        ConformanceTest {
123            id: "jws-tampered-payload-rejected",
124            category: "jws",
125            run: || Box::pin(test_jws_tampered_payload_rejected()),
126        },
127        ConformanceTest {
128            id: "ssrf-rejects-loopback",
129            category: "ssrf",
130            run: || Box::pin(test_ssrf_rejects_loopback()),
131        },
132        ConformanceTest {
133            id: "ssrf-rejects-rfc1918",
134            category: "ssrf",
135            run: || Box::pin(test_ssrf_rejects_rfc1918()),
136        },
137        ConformanceTest {
138            id: "ssrf-rejects-link-local",
139            category: "ssrf",
140            run: || Box::pin(test_ssrf_rejects_link_local()),
141        },
142        ConformanceTest {
143            id: "ssrf-accepts-public-ips",
144            category: "ssrf",
145            run: || Box::pin(test_ssrf_accepts_public_ips()),
146        },
147        ConformanceTest {
148            id: "clock-skew-60s-window",
149            category: "time",
150            run: || Box::pin(test_clock_skew_60s_window()),
151        },
152        ConformanceTest {
153            id: "clock-skew-rejects-outside-window",
154            category: "time",
155            run: || Box::pin(test_clock_skew_rejects_outside_window()),
156        },
157        ConformanceTest {
158            id: "did-uri-parser-strict",
159            category: "identity",
160            run: || Box::pin(test_did_uri_parser_strict()),
161        },
162        ConformanceTest {
163            id: "did-key-roundtrip",
164            category: "identity",
165            run: || Box::pin(test_did_key_roundtrip()),
166        },
167        ConformanceTest {
168            id: "did-key-rejects-malformed",
169            category: "identity",
170            run: || Box::pin(test_did_key_rejects_malformed()),
171        },
172        ConformanceTest {
173            id: "capability-bits-stable",
174            category: "capability",
175            run: || Box::pin(test_capability_bits_stable()),
176        },
177        ConformanceTest {
178            id: "capability-forward-compat",
179            category: "capability",
180            run: || Box::pin(test_capability_forward_compat()),
181        },
182        ConformanceTest {
183            id: "wire-v2-rejects-nonce-too-short",
184            category: "wire",
185            run: || Box::pin(test_wire_v2_rejects_nonce_too_short()),
186        },
187        ConformanceTest {
188            id: "wire-v2-rejects-newline-in-fields",
189            category: "wire",
190            run: || Box::pin(test_wire_v2_rejects_newline_in_fields()),
191        },
192        ConformanceTest {
193            id: "wire-v2-rotate-key-same-keys-rejected",
194            category: "wire",
195            run: || Box::pin(test_wire_v2_rotate_key_same_keys_rejected()),
196        },
197        ConformanceTest {
198            id: "wire-v2-receipt-action-whitelist",
199            category: "wire",
200            run: || Box::pin(test_wire_v2_receipt_action_whitelist()),
201        },
202        ConformanceTest {
203            id: "decision-request-bytes-stable",
204            category: "deferred-decision",
205            run: || Box::pin(test_decision_request_bytes_stable()),
206        },
207        ConformanceTest {
208            id: "decision-response-bytes-stable",
209            category: "deferred-decision",
210            run: || Box::pin(test_decision_response_bytes_stable()),
211        },
212        ConformanceTest {
213            id: "deferred-decision-capability-bit-stable",
214            category: "deferred-decision",
215            run: || Box::pin(test_deferred_decision_capability_bit_stable()),
216        },
217    ]
218}
219
220/// Run every test and collect results.
221pub async fn run_all() -> Vec<ConformanceResult> {
222    let mut results = Vec::new();
223    for t in all_tests() {
224        let outcome = match (t.run)().await {
225            Ok(()) => Outcome::Pass,
226            Err(msg) => Outcome::Fail(msg),
227        };
228        results.push(ConformanceResult {
229            id: t.id,
230            category: t.category,
231            outcome,
232        });
233    }
234    results
235}
236
237// ── Test implementations ─────────────────────────────────────────────
238
239const NONCE: &str = "0123456789abcdef0123456789abcdef";
240
241async fn test_wire_v2_roundtrip() -> Result<(), String> {
242    let bytes = wire_v2::transfer_intent_bytes_v2(
243        "did:web:acme.com#agent",
244        "did:web:beta.com#bob",
245        12345,
246        "application/pdf",
247        "x.pdf",
248        NONCE,
249        1_700_000_000,
250    )
251    .map_err(|e| format!("encode failed: {}", e))?;
252    let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
253    if !s.starts_with("aex-transfer-intent:v2\n") {
254        return Err(format!("unexpected prefix: {:?}", &s[..30]));
255    }
256    Ok(())
257}
258
259async fn test_wire_v1_still_functional() -> Result<(), String> {
260    let bytes = wire::transfer_intent_bytes(
261        "spize:acme/alice:aabbcc",
262        "spize:acme/bob:ddeeff",
263        100,
264        "",
265        "",
266        NONCE,
267        1_700_000_000,
268    )
269    .map_err(|e| e.to_string())?;
270    let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
271    if !s.starts_with("spize-transfer-intent:v1\n") {
272        return Err(format!("v1 prefix missing: {:?}", &s[..30]));
273    }
274    Ok(())
275}
276
277async fn test_cross_version_isolation() -> Result<(), String> {
278    let v1 = wire::registration_challenge_bytes("aa", "acme", "alice", NONCE, 1_700_000_000)
279        .map_err(|e| e.to_string())?;
280    let v2 = wire_v2::registration_challenge_bytes_v2("aa", "acme", "alice", NONCE, 1_700_000_000)
281        .map_err(|e| e.to_string())?;
282    if v1 == v2 {
283        return Err("v1 and v2 bytes collide for identical inputs".into());
284    }
285    if !std::str::from_utf8(&v1).unwrap().starts_with("spize-") {
286        return Err("v1 missing spize- prefix".into());
287    }
288    if !std::str::from_utf8(&v2).unwrap().starts_with("aex-") {
289        return Err("v2 missing aex- prefix".into());
290    }
291    Ok(())
292}
293
294async fn test_jws_algorithm_whitelist() -> Result<(), String> {
295    let sk = SigningKey::from_bytes(&[1u8; 32]);
296    let jws = sign_ed25519(b"payload", &sk, "did:key:test").map_err(|e| e.to_string())?;
297    let _ = verify(&jws, |_| Ok(Some(VerifierKey::Ed25519(sk.verifying_key()))))
298        .map_err(|e| e.to_string())?;
299    Ok(())
300}
301
302async fn test_jws_alg_none_rejected() -> Result<(), String> {
303    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
304    use base64::Engine;
305    let header = serde_json::json!({"alg": "none", "kid": "did:web:attacker"});
306    let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
307    let p = URL_SAFE_NO_PAD.encode(b"forged");
308    let jws = format!("{}.{}.", h, p);
309    match verify(&jws, |_| Ok(None)) {
310        Err(JwsError::AlgorithmNotPermitted(s)) if s == "none" => Ok(()),
311        other => Err(format!("expected AlgorithmNotPermitted, got {:?}", other)),
312    }
313}
314
315async fn test_jws_alg_hs256_rejected() -> Result<(), String> {
316    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
317    use base64::Engine;
318    let header = serde_json::json!({"alg": "HS256", "kid": "did:web:attacker"});
319    let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
320    let p = URL_SAFE_NO_PAD.encode(b"forged");
321    let s = URL_SAFE_NO_PAD.encode([0u8; 32]);
322    let jws = format!("{}.{}.{}", h, p, s);
323    match verify(&jws, |_| Ok(None)) {
324        Err(JwsError::AlgorithmNotPermitted(s)) if s == "HS256" => Ok(()),
325        other => Err(format!("expected AlgorithmNotPermitted, got {:?}", other)),
326    }
327}
328
329async fn test_jws_tampered_payload_rejected() -> Result<(), String> {
330    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
331    use base64::Engine;
332    let sk = SigningKey::from_bytes(&[2u8; 32]);
333    let vk = sk.verifying_key();
334    let jws = sign_ed25519(b"original", &sk, "did:web:bob").unwrap();
335    let parts: Vec<&str> = jws.split('.').collect();
336    let tampered_p = URL_SAFE_NO_PAD.encode(b"forged");
337    let bad = format!("{}.{}.{}", parts[0], tampered_p, parts[2]);
338    match verify(&bad, |_| Ok(Some(VerifierKey::Ed25519(vk)))) {
339        Err(JwsError::BadSignature(_)) => Ok(()),
340        other => Err(format!("expected BadSignature, got {:?}", other)),
341    }
342}
343
344async fn test_ssrf_rejects_loopback() -> Result<(), String> {
345    if !is_forbidden_ip("127.0.0.1".parse().unwrap()) {
346        return Err("127.0.0.1 not flagged forbidden".into());
347    }
348    if !is_forbidden_ip("::1".parse().unwrap()) {
349        return Err("::1 not flagged forbidden".into());
350    }
351    Ok(())
352}
353
354async fn test_ssrf_rejects_rfc1918() -> Result<(), String> {
355    for ip in ["10.0.0.1", "172.16.0.1", "192.168.0.1"] {
356        if !is_forbidden_ip(ip.parse().unwrap()) {
357            return Err(format!("{} not flagged forbidden", ip));
358        }
359    }
360    Ok(())
361}
362
363async fn test_ssrf_rejects_link_local() -> Result<(), String> {
364    if !is_forbidden_ip("169.254.169.254".parse().unwrap()) {
365        return Err("EC2 metadata IP 169.254.169.254 not flagged".into());
366    }
367    if !is_forbidden_ip("fe80::1".parse().unwrap()) {
368        return Err("fe80::1 not flagged".into());
369    }
370    Ok(())
371}
372
373async fn test_ssrf_accepts_public_ips() -> Result<(), String> {
374    for ip in ["1.1.1.1", "8.8.8.8"] {
375        if is_forbidden_ip(ip.parse().unwrap()) {
376            return Err(format!("public IP {} unexpectedly flagged", ip));
377        }
378    }
379    Ok(())
380}
381
382async fn test_clock_skew_60s_window() -> Result<(), String> {
383    let now = 1_700_000_000;
384    if !wire_v2::is_within_clock_skew_v2(now, now) {
385        return Err("zero skew rejected".into());
386    }
387    if !wire_v2::is_within_clock_skew_v2(now, now - 60) {
388        return Err("-60s skew rejected".into());
389    }
390    if !wire_v2::is_within_clock_skew_v2(now, now + 60) {
391        return Err("+60s skew rejected".into());
392    }
393    Ok(())
394}
395
396async fn test_clock_skew_rejects_outside_window() -> Result<(), String> {
397    let now = 1_700_000_000;
398    if wire_v2::is_within_clock_skew_v2(now, now - 61) {
399        return Err("-61s skew accepted".into());
400    }
401    if wire_v2::is_within_clock_skew_v2(now, now + 61) {
402        return Err("+61s skew accepted".into());
403    }
404    Ok(())
405}
406
407async fn test_did_uri_parser_strict() -> Result<(), String> {
408    // Empty fragment must be rejected by the URI parser.
409    let id = AgentId::new("did:web:acme.com#").map_err(|e| e.to_string())?;
410    if id.as_did_uri().is_some() {
411        return Err("empty fragment accepted by parser".into());
412    }
413    // Uppercase method must be rejected by the URI parser.
414    let id = AgentId::new("did:WEB:acme.com").map_err(|e| e.to_string())?;
415    if id.as_did_uri().is_some() {
416        return Err("uppercase method accepted".into());
417    }
418    // Well-formed URI must parse.
419    let id = AgentId::new("did:web:acme.com#x").map_err(|e| e.to_string())?;
420    let u = id
421        .as_did_uri()
422        .ok_or_else(|| "well-formed URI failed to parse".to_string())?;
423    if u.method != "web" || u.method_specific_id != "acme.com" {
424        return Err("parse extracted wrong components".into());
425    }
426    Ok(())
427}
428
429async fn test_did_key_roundtrip() -> Result<(), String> {
430    let p = DidKeyProvider::generate().map_err(|e| e.to_string())?;
431    if p.agent_id().scheme() != IdScheme::DidKey {
432        return Err("generated did:key has wrong scheme".into());
433    }
434    let _ = DidKeyProvider::decode_pubkey(p.agent_id()).map_err(|e| e.to_string())?;
435    Ok(())
436}
437
438async fn test_did_key_rejects_malformed() -> Result<(), String> {
439    let id = AgentId::new("did:key:fabc").map_err(|e| e.to_string())?;
440    if DidKeyProvider::decode_pubkey(&id).is_ok() {
441        return Err("malformed did:key accepted".into());
442    }
443    Ok(())
444}
445
446async fn test_capability_bits_stable() -> Result<(), String> {
447    let expected: &[(Capability, u8, &str)] = &[
448        (Capability::WireV2, 0, "wire-v2"),
449        (Capability::JwsAgentCard, 1, "jws-agent-card"),
450        (Capability::CardEtag, 2, "card-etag"),
451        (Capability::A2ABridge, 3, "a2a-bridge"),
452        (Capability::EtereCitizenTrust, 4, "etere-citizen-trust"),
453        (Capability::SafeHttp, 5, "safe-http"),
454        (Capability::ClockSkew60s, 6, "clock-skew-60s"),
455        (Capability::StreamingTransfer, 7, "streaming-transfer"),
456    ];
457    for (cap, bit, name) in expected {
458        if cap.as_bit() != *bit {
459            return Err(format!(
460                "capability {:?} bit changed: {} != {}",
461                cap,
462                cap.as_bit(),
463                bit
464            ));
465        }
466        if cap.as_str() != *name {
467            return Err(format!(
468                "capability {:?} name changed: {:?} != {:?}",
469                cap,
470                cap.as_str(),
471                name
472            ));
473        }
474    }
475    Ok(())
476}
477
478async fn test_capability_forward_compat() -> Result<(), String> {
479    // Unknown names must be silently dropped, not errored.
480    let set = CapabilitySet::from_string_array([
481        "wire-v2",
482        "future-capability-the-future-invented",
483        "jws-agent-card",
484    ]);
485    if !set.has(Capability::WireV2) || !set.has(Capability::JwsAgentCard) {
486        return Err("known caps lost during forward-compat parse".into());
487    }
488    if set.to_string_array().len() != 2 {
489        return Err(format!(
490            "expected 2 known caps after dropping unknown, got {}",
491            set.to_string_array().len()
492        ));
493    }
494    Ok(())
495}
496
497async fn test_wire_v2_rejects_nonce_too_short() -> Result<(), String> {
498    let r =
499        wire_v2::registration_challenge_bytes_v2("aa", "acme", "alice", "deadbeef", 1_700_000_000);
500    if r.is_ok() {
501        return Err("short nonce accepted".into());
502    }
503    Ok(())
504}
505
506async fn test_wire_v2_rejects_newline_in_fields() -> Result<(), String> {
507    let r = wire_v2::registration_challenge_bytes_v2("aa", "ac\nme", "alice", NONCE, 1_700_000_000);
508    if r.is_ok() {
509        return Err("newline in org field accepted".into());
510    }
511    Ok(())
512}
513
514async fn test_wire_v2_rotate_key_same_keys_rejected() -> Result<(), String> {
515    let same = "a".repeat(64);
516    let r = wire_v2::rotate_key_challenge_bytes_v2(
517        "did:spize:acme/alice#aabbcc",
518        &same,
519        &same,
520        NONCE,
521        1_700_000_000,
522    );
523    if r.is_ok() {
524        return Err("identical old/new keys accepted in rotate".into());
525    }
526    Ok(())
527}
528
529async fn test_wire_v2_receipt_action_whitelist() -> Result<(), String> {
530    // Accept all four allowed actions.
531    for action in ["download", "ack", "inbox", "request_ticket"] {
532        wire_v2::transfer_receipt_bytes_v2(
533            "did:web:bob.com#x",
534            "tx_abc",
535            action,
536            NONCE,
537            1_700_000_000,
538        )
539        .map_err(|e| format!("action {} rejected: {}", action, e))?;
540    }
541    // Reject anything else.
542    let r = wire_v2::transfer_receipt_bytes_v2(
543        "did:web:bob.com#x",
544        "tx_abc",
545        "overwrite",
546        NONCE,
547        1_700_000_000,
548    );
549    if r.is_ok() {
550        return Err("non-whitelisted action accepted".into());
551    }
552    Ok(())
553}
554
555async fn test_decision_request_bytes_stable() -> Result<(), String> {
556    let bytes = wire_v2::decision_request_bytes_v2(
557        "did:web:acme.com#agent-vendite",
558        "tx_abc123",
559        "dec_0001",
560        86_400,
561        NONCE,
562        1_700_000_000,
563    )
564    .map_err(|e| e.to_string())?;
565    let s = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
566    if !s.starts_with("aex-decision-request:v2\n") {
567        return Err(format!("unexpected prefix: {:?}", &s[..40]));
568    }
569    if !s.contains("decision=dec_0001\n") {
570        return Err("decision id field missing".into());
571    }
572    if !s.contains("eta_secs=86400\n") {
573        return Err("eta_secs field missing or malformed".into());
574    }
575    Ok(())
576}
577
578async fn test_decision_response_bytes_stable() -> Result<(), String> {
579    let accepted = wire_v2::decision_response_bytes_v2(
580        "did:web:acme.com#agent-vendite",
581        "tx_abc123",
582        "dec_0001",
583        "accepted",
584        "",
585        NONCE,
586        1_700_000_000,
587    )
588    .map_err(|e| e.to_string())?;
589    if !std::str::from_utf8(&accepted)
590        .unwrap()
591        .starts_with("aex-decision-response:v2\n")
592    {
593        return Err("accepted prefix wrong".into());
594    }
595    // Outcome whitelist enforced
596    let bad =
597        wire_v2::decision_response_bytes_v2("x", "tx", "dec", "maybe", "", NONCE, 1_700_000_000);
598    if bad.is_ok() {
599        return Err("non-whitelisted outcome accepted".into());
600    }
601    Ok(())
602}
603
604async fn test_deferred_decision_capability_bit_stable() -> Result<(), String> {
605    if Capability::DeferredDecision.as_bit() != 8 {
606        return Err(format!(
607            "DeferredDecision bit changed: {}",
608            Capability::DeferredDecision.as_bit()
609        ));
610    }
611    if Capability::DeferredDecision.as_str() != "deferred-decision" {
612        return Err(format!(
613            "DeferredDecision name changed: {:?}",
614            Capability::DeferredDecision.as_str()
615        ));
616    }
617    // Forward-compat: a set containing the bit must still parse and
618    // serialize the bit back correctly.
619    let set = CapabilitySet::empty().with(Capability::DeferredDecision);
620    if !set.has(Capability::DeferredDecision) {
621        return Err("set membership broken".into());
622    }
623    let names = set.to_string_array();
624    if !names.contains(&"deferred-decision") {
625        return Err("string array missing deferred-decision".into());
626    }
627    Ok(())
628}
629
630/// Useful in tests: poke `Duration` so the unused-import warning
631/// doesn't fire on bookkeeping helpers we may add later.
632#[doc(hidden)]
633pub const _DURATION_HINT: Duration = Duration::from_secs(0);
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[tokio::test]
640    async fn full_suite_passes_against_local_stack() {
641        let results = run_all().await;
642        let failed: Vec<_> = results.iter().filter(|r| !r.outcome.is_pass()).collect();
643        assert!(
644            failed.is_empty(),
645            "conformance failures: {:#?}",
646            failed
647                .iter()
648                .map(|r| format!("{}: {}", r.id, r.outcome))
649                .collect::<Vec<_>>()
650        );
651        // Sanity: we expect a stable test count.
652        assert_eq!(results.len(), 25);
653    }
654
655    #[tokio::test]
656    async fn every_test_has_unique_id() {
657        let tests = all_tests();
658        let mut seen = std::collections::HashSet::new();
659        for t in &tests {
660            assert!(seen.insert(t.id), "duplicate test id: {}", t.id);
661        }
662    }
663}