返回 CodeWhale
report.rs
根目录 / crates / tui / src / tools / github / report.rs
1 //! Local, immutable Codewhale issue drafts in the existing session artifact owner.
2 //! No network, provider client, log reader or publication authority lives here.
3
4 use std::collections::BTreeSet;
5 use std::fs::File;
6 use std::io::{self, Read, Write};
7 use std::path::{Path, PathBuf};
8
9 use serde::{Deserialize, Serialize};
10 use serde_json::{Value, json};
11 use sha2::{Digest, Sha256};
12
13 use crate::tools::spec::{ToolContext, ToolError, ToolResult};
14
15 const MAX_BYTES: usize = 32 * 1024;
16 const REPOSITORY: &str = "Hmbown/CodeWhale";
17
18 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19 #[serde(deny_unknown_fields)]
20 pub(crate) struct ReportFields {
21 pub title: String,
22 pub expected: String,
23 pub actual: String,
24 pub steps: Vec<String>,
25 pub observed: Vec<String>,
26 #[serde(default)]
27 pub inferred: Vec<String>,
28 pub impact: String,
29 #[serde(default)]
30 pub reported_provider: Option<String>,
31 #[serde(default)]
32 pub reported_tool: Option<String>,
33 #[serde(default)]
34 pub reported_terminal: Option<String>,
35 #[serde(default)]
36 pub related_issues: Vec<u32>,
37 }
38
39 #[derive(Debug, Serialize, Deserialize)]
40 #[serde(deny_unknown_fields)]
41 pub(crate) struct Report {
42 schema_version: u8,
43 session: String,
44 pub id: String,
45 pub revises: Option<String>,
46 pub fields: ReportFields,
47 version: String,
48 platform: String,
49 model: String,
50 redactions: BTreeSet<String>,
51 }
52
53 fn invalid() -> ToolError {
54 ToolError::invalid_input(
55 "Invalid issue draft: use bounded narrative fields and an existing session draft ID; omit logs, code blocks and attachments.",
56 )
57 }
58
59 fn storage_error(_: io::Error) -> ToolError {
60 ToolError::execution_failed(
61 "Issue draft storage is unavailable or invalid. No report was posted; an earlier draft may still be available.",
62 )
63 }
64
65 pub(crate) fn safe_text(
66 raw: &str,
67 max: usize,
68 kinds: &mut BTreeSet<String>,
69 ) -> Result<String, ToolError> {
70 if raw.len() > max * 4
71 || raw.contains('`')
72 || raw.contains("](")
73 || raw.contains("\\/")
74 || raw.chars().any(|ch| {
75 (ch.is_control() && !ch.is_whitespace())
76 || matches!(ch, '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}')
77 })
78 {
79 return Err(invalid());
80 }
81 // The shared disclosure redactor requires space-separated tokens. Keep a
82 // whole leaf together so Authorization/Bearer state survives line breaks.
83 let normalized = raw
84 .split_whitespace()
85 .map(|word| {
86 let unwrapped = word
87 .trim_start_matches(['(', '[', '{', '"', '\'', '<', '*', '“', '‘'])
88 .trim_end_matches([
89 ',', '.', ';', ':', ')', ']', '}', '"', '\'', '>', '*', '”', '’', '!', '?',
90 ]);
91 if unwrapped.contains("://") || unwrapped.starts_with("www.") {
92 kinds.insert("url".into());
93 return "[redacted-url]".to_string();
94 }
95 // The shared prefix detector examines raw tokens. Probe unwrapped
96 // prose too, preserving punctuation unless it conceals a disclosure.
97 // xAI keys are not yet included in the shared prefix list.
98 if unwrapped.to_ascii_lowercase().starts_with("xai-") {
99 kinds.insert("secret".into());
100 return "<redacted>".to_string();
101 }
102 let probe = codewhale_workflow::redaction::redact_for_disclosure(unwrapped);
103 if probe.text() != unwrapped {
104 kinds.extend(probe.kinds());
105 probe.into_text()
106 } else {
107 word.to_string()
108 }
109 })
110 .collect::<Vec<_>>()
111 .join(" ");
112 let redacted = codewhale_workflow::redaction::redact_for_disclosure(&normalized);
113 kinds.extend(redacted.kinds());
114 let text = redacted.into_text();
115 if text.is_empty() || text.len() > max {
116 return Err(invalid());
117 }
118 Ok(text)
119 }
120
121 impl ReportFields {
122 fn normalize(&mut self, kinds: &mut BTreeSet<String>) -> Result<(), ToolError> {
123 self.title = safe_text(&self.title, 160, kinds)?;
124 for field in [&mut self.expected, &mut self.actual, &mut self.impact] {
125 *field = safe_text(field, 1600, kinds)?;
126 }
127 for (items, required, cap) in [
128 (&mut self.steps, true, 8),
129 (&mut self.observed, true, 8),
130 (&mut self.inferred, false, 4),
131 ] {
132 if items.len() > cap || (required && items.is_empty()) {
133 return Err(invalid());
134 }
135 for item in items {
136 *item = safe_text(item, 800, kinds)?;
137 }
138 }
139 for field in [
140 &mut self.reported_provider,
141 &mut self.reported_tool,
142 &mut self.reported_terminal,
143 ]
144 .into_iter()
145 .flatten()
146 {
147 *field = safe_text(field, 100, kinds)?;
148 }
149 if self.related_issues.len() > 5 || self.related_issues.contains(&0) {
150 return Err(invalid());
151 }
152 self.related_issues.sort_unstable();
153 self.related_issues.dedup();
154 Ok(())
155 }
156 }
157
158 fn valid_id(id: &str) -> bool {
159 id.strip_prefix("cwreport_").is_some_and(|digest| {
160 digest.len() == 64
161 && digest
162 .bytes()
163 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
164 })
165 }
166
167 impl Report {
168 fn digest_id(&self) -> Result<String, ToolError> {
169 // Origin call IDs/timestamps are not identity: an identical retry must
170 // converge even when the Engine assigns a fresh call ID.
171 let bytes = serde_json::to_vec(&json!({
172 "schema_version": self.schema_version, "session": self.session,
173 "revises": self.revises, "fields": self.fields, "version": self.version,
174 "platform": self.platform, "model": self.model,
175 }))
176 .map_err(|_| invalid())?;
177 let digest = Sha256::digest(bytes)
178 .iter()
179 .map(|byte| format!("{byte:02x}"))
180 .collect::<String>();
181 Ok(format!("cwreport_{digest}"))
182 }
183
184 pub(crate) fn render_review(&self) -> String {
185 let mut out = format!(
186 "# {}\n\nDraft: {}\nStatus: ready for review\nPublication: unavailable\nDuplicate search: not performed\nDestination: {REPOSITORY}\n\nReview the contents before sharing. Redaction does not guarantee privacy.\n",
187 self.fields.title, self.id
188 );
189 if let Some(previous) = &self.revises {
190 out.push_str(&format!("Revises: {previous}\n"));
191 }
192 for (heading, text) in [
193 ("Expected behavior", &self.fields.expected),
194 ("Actual behavior", &self.fields.actual),
195 ("Impact", &self.fields.impact),
196 ] {
197 out.push_str(&format!("\n## {heading}\n\n{text}\n"));
198 }
199 for (heading, items) in [
200 ("Steps to reproduce (agent reported)", &self.fields.steps),
201 ("Observed by the agent", &self.fields.observed),
202 ("Inferences (not verified)", &self.fields.inferred),
203 ] {
204 out.push_str(&format!("\n## {heading}\n\n"));
205 if items.is_empty() {
206 out.push_str("None recorded.\n");
207 }
208 for item in items {
209 out.push_str(&format!("- {item}\n"));
210 }
211 }
212 out.push_str(&format!(
213 "\n## Runtime context\n\n- Codewhale: {}\n- Platform: {}\n- Active model: {}\n",
214 self.version, self.platform, self.model
215 ));
216 for (label, value) in [
217 ("Provider", &self.fields.reported_provider),
218 ("Tool", &self.fields.reported_tool),
219 ("Terminal", &self.fields.reported_terminal),
220 ] {
221 out.push_str(&format!(
222 "- {label} (agent reported): {}\n",
223 value.as_deref().unwrap_or("unknown")
224 ));
225 }
226 if !self.fields.related_issues.is_empty() {
227 out.push_str("\n## Related issues (agent supplied; not verified or searched)\n\n");
228 for number in &self.fields.related_issues {
229 out.push_str(&format!(
230 "- [#{number}](https://github.com/{REPOSITORY}/issues/{number})\n"
231 ));
232 }
233 }
234 if !self.redactions.is_empty() {
235 out.push_str(&format!(
236 "\nRedacted categories: {}\n",
237 self.redactions
238 .iter()
239 .cloned()
240 .collect::<Vec<_>>()
241 .join(", ")
242 ));
243 }
244 out.push_str(&format!(
245 "\nReview: `/feedback review {}`\nRevise: `/feedback edit {} <change>`\n",
246 self.id, self.id
247 ));
248 out
249 }
250
251 fn tool_result(&self, directory: &Path) -> Result<ToolResult, ToolError> {
252 let relative = format!("artifacts/issue-reports/{}.json", self.id);
253 let bytes = serde_json::to_vec(self).map_err(|_| invalid())?;
254 let body = self.render_review();
255 Ok(ToolResult::json(&json!({"report_id": self.id, "revises": self.revises,
256 "state": "ready_for_review", "publication": "unavailable", "duplicate_search": "not_performed",
257 "review": body, "artifact": relative
258 })).map_err(|_| invalid())?.with_metadata(json!({
259 "spillover_path": directory.join(format!("{}.json", self.id)),
260 "artifact_session_id": self.session, "artifact_relative_path": relative,
261 "artifact_byte_size": bytes.len(), "artifact_preview": self.fields.title,
262 })))
263 }
264 }
265
266 #[derive(Deserialize)]
267 #[serde(deny_unknown_fields)]
268 struct DraftInput {
269 action: String,
270 report: ReportFields,
271 #[serde(default)]
272 revises: Option<String>,
273 }
274
275 #[derive(Deserialize)]
276 #[serde(deny_unknown_fields)]
277 struct ReadInput {
278 action: String,
279 report_id: String,
280 }
281
282 pub(super) fn draft(input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
283 if input.to_string().len() > MAX_BYTES {
284 return Err(invalid());
285 }
286 let input: DraftInput = serde_json::from_value(input).map_err(|_| invalid())?;
287 if input.action != "report_draft" {
288 return Err(invalid());
289 }
290 let snapshot = context
291 .session_objects
292 .as_ref()
293 .filter(|snapshot| snapshot.session_id == context.state_namespace)
294 .ok_or_else(|| {
295 ToolError::not_available("Issue drafting requires the active Engine session context.")
296 })?;
297 let report = create(
298 &context.state_namespace,
299 &snapshot.model,
300 input.report,
301 input.revises,
302 )?;
303 report.tool_result(&directory_path(&context.state_namespace, false)?)
304 }
305
306 pub(super) fn read(input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
307 let input: ReadInput = serde_json::from_value(input).map_err(|_| invalid())?;
308 if input.action != "report_read" {
309 return Err(invalid());
310 }
311 let report = load(&context.state_namespace, &input.report_id)?;
312 report.tool_result(&directory_path(&context.state_namespace, false)?)
313 }
314
315 fn create(
316 session: &str,
317 model: &str,
318 mut fields: ReportFields,
319 revises: Option<String>,
320 ) -> Result<Report, ToolError> {
321 let mut redactions = BTreeSet::new();
322 fields.normalize(&mut redactions)?;
323 let model = safe_text(model, 160, &mut redactions)?;
324 if let Some(id) = &revises {
325 load(session, id)?;
326 }
327 let mut report = Report {
328 schema_version: 1,
329 session: session.into(),
330 id: String::new(),
331 revises,
332 fields,
333 version: env!("CARGO_PKG_VERSION").into(),
334 platform: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
335 model,
336 redactions,
337 };
338 report.id = report.digest_id()?;
339 let bytes = serde_json::to_vec(&report).map_err(|_| invalid())?;
340 if bytes.len() > MAX_BYTES {
341 return Err(invalid());
342 }
343 let dir =
344 AnchoredDirectory::open(&directory_path(session, true)?, true).map_err(storage_error)?;
345 let name = format!("{}.json", report.id);
346 match dir.publish(&name, &bytes) {
347 Ok(()) => load(session, &report.id),
348 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
349 load_from(&dir, session, &report.id)
350 }
351 Err(error) => Err(storage_error(error)),
352 }
353 }
354
355 pub(crate) fn load(session: &str, id: &str) -> Result<Report, ToolError> {
356 if !valid_id(id) {
357 return Err(invalid());
358 }
359 let dir =
360 AnchoredDirectory::open(&directory_path(session, false)?, false).map_err(storage_error)?;
361 load_from(&dir, session, id)
362 }
363
364 fn load_from(dir: &AnchoredDirectory, session: &str, id: &str) -> Result<Report, ToolError> {
365 let bytes = dir.read(&format!("{id}.json")).map_err(storage_error)?;
366 let report: Report = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
367 if report.schema_version != 1
368 || report.session != session
369 || report.id != id
370 || report.digest_id()? != id
371 {
372 return Err(invalid());
373 }
374 // Revalidate loaded fields too; on-disk artifacts are not instruction or
375 // disclosure authority, even when an attacker recomputes their digest.
376 let mut fields = report.fields.clone();
377 let mut kinds = BTreeSet::new();
378 fields.normalize(&mut kinds)?;
379 if fields != report.fields
380 || safe_text(&report.model, 160, &mut kinds)? != report.model
381 || report.revises.as_deref().is_some_and(|id| !valid_id(id))
382 || report.redactions.iter().any(|kind| {
383 !matches!(
384 kind.as_str(),
385 "url" | "absolute_path" | "relative_path" | "secret"
386 )
387 })
388 || safe_text(&report.version, 100, &mut kinds)? != report.version
389 || safe_text(&report.platform, 100, &mut kinds)? != report.platform
390 {
391 return Err(invalid());
392 }
393 Ok(report)
394 }
395
396 fn directory_path(session: &str, create: bool) -> Result<PathBuf, ToolError> {
397 let path = crate::artifacts::session_artifact_absolute_path(
398 session,
399 Path::new("artifacts/issue-reports"),
400 )
401 .ok_or_else(invalid)?;
402 // Only the configured state root is trusted to resolve platform aliases
403 // (e.g. macOS /var). Session/artifact descendants stay uncanonicalized and
404 // are opened component-by-component without following links below.
405 let root = path.ancestors().nth(4).ok_or_else(invalid)?;
406 if create {
407 std::fs::create_dir_all(root).map_err(storage_error)?;
408 }
409 let root = root.canonicalize().map_err(storage_error)?;
410 Ok(root
411 .join("sessions")
412 .join(session)
413 .join("artifacts/issue-reports"))
414 }
415
416 fn bounded_read(mut file: File) -> io::Result<Vec<u8>> {
417 let metadata = file.metadata()?;
418 if !metadata.is_file() || metadata.len() > MAX_BYTES as u64 {
419 return Err(io::ErrorKind::InvalidData.into());
420 }
421 let mut bytes = Vec::new();
422 Read::by_ref(&mut file)
423 .take(MAX_BYTES as u64 + 1)
424 .read_to_end(&mut bytes)?;
425 if bytes.len() > MAX_BYTES {
426 return Err(io::ErrorKind::InvalidData.into());
427 }
428 Ok(bytes)
429 }
430
431 // Adapt the repository's anchored credential-file primitives, without calling
432 // credential APIs. The artifact owner/path stays the existing session owner.
433 #[cfg(unix)]
434 struct AnchoredDirectory(File);
435
436 #[cfg(unix)]
437 impl AnchoredDirectory {
438 fn open(path: &Path, create: bool) -> io::Result<Self> {
439 use std::os::fd::{AsRawFd, FromRawFd};
440 use std::os::unix::ffi::OsStrExt;
441 use std::os::unix::fs::{MetadataExt, PermissionsExt};
442 use std::path::Component;
443 // SAFETY: constant C string; successful descriptor immediately owned.
444 let fd = unsafe {
445 libc::open(
446 c"/".as_ptr(),
447 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
448 )
449 };
450 if fd < 0 {
451 return Err(io::Error::last_os_error());
452 }
453 // SAFETY: fd is freshly owned.
454 let mut current = unsafe { File::from_raw_fd(fd) };
455 for component in path.components() {
456 let Component::Normal(name) = component else {
457 if component == Component::RootDir {
458 continue;
459 }
460 return Err(io::ErrorKind::InvalidInput.into());
461 };
462 let name = std::ffi::CString::new(name.as_bytes())?;
463 let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW;
464 // SAFETY: parent fd and single component C string remain valid.
465 let mut fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) };
466 if fd < 0 && create && io::Error::last_os_error().kind() == io::ErrorKind::NotFound {
467 // SAFETY: mkdirat operates beneath the pinned parent only.
468 if unsafe { libc::mkdirat(current.as_raw_fd(), name.as_ptr(), 0o700) } != 0
469 && io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
470 {
471 return Err(io::Error::last_os_error());
472 }
473 // SAFETY: same pinned parent and name; refuses raced symlinks.
474 fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) };
475 }
476 if fd < 0 {
477 return Err(io::Error::last_os_error());
478 }
479 // SAFETY: fd is freshly owned.
480 current = unsafe { File::from_raw_fd(fd) };
481 }
482 // SAFETY: geteuid has no preconditions.
483 if current.metadata()?.uid() != unsafe { libc::geteuid() } {
484 return Err(io::ErrorKind::PermissionDenied.into());
485 }
486 if create {
487 current.set_permissions(std::fs::Permissions::from_mode(0o700))?;
488 } else if current.metadata()?.mode() & 0o077 != 0 {
489 return Err(io::ErrorKind::PermissionDenied.into());
490 }
491 Ok(Self(current))
492 }
493
494 fn file(&self, name: &str, flags: i32) -> io::Result<File> {
495 use std::os::fd::{AsRawFd, FromRawFd};
496 let name = std::ffi::CString::new(name)?;
497 // SAFETY: name is a generated basename and self pins its directory.
498 let fd = unsafe {
499 libc::openat(
500 self.0.as_raw_fd(),
501 name.as_ptr(),
502 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
503 0o600,
504 )
505 };
506 if fd < 0 {
507 return Err(io::Error::last_os_error());
508 }
509 // SAFETY: fd is freshly owned.
510 Ok(unsafe { File::from_raw_fd(fd) })
511 }
512
513 fn read(&self, name: &str) -> io::Result<Vec<u8>> {
514 use std::os::unix::fs::MetadataExt;
515 let file = self.file(name, libc::O_RDONLY)?;
516 let metadata = file.metadata()?;
517 // SAFETY: geteuid has no preconditions. Reject hardlinks and FIFOs.
518 if metadata.uid() != unsafe { libc::geteuid() }
519 || metadata.nlink() != 1
520 || metadata.mode() & 0o077 != 0
521 {
522 return Err(io::ErrorKind::PermissionDenied.into());
523 }
524 bounded_read(file)
525 }
526
527 fn publish(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
528 use std::os::fd::AsRawFd;
529 let temp = format!(".draft-{}.tmp", uuid::Uuid::new_v4());
530 let mut file = self.file(&temp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
531 let temporary = std::ffi::CString::new(temp)?;
532 let target = std::ffi::CString::new(name)?;
533 let result = (|| {
534 file.write_all(bytes)?;
535 file.sync_all()?;
536 // SAFETY: both basenames are under the same pinned directory.
537 if unsafe {
538 libc::linkat(
539 self.0.as_raw_fd(),
540 temporary.as_ptr(),
541 self.0.as_raw_fd(),
542 target.as_ptr(),
543 0,
544 )
545 } != 0
546 {
547 return Err(io::Error::last_os_error());
548 }
549 Ok(())
550 })();
551 // SAFETY: remove only our generated staging name beneath the pinned fd.
552 if unsafe { libc::unlinkat(self.0.as_raw_fd(), temporary.as_ptr(), 0) } != 0 {
553 return Err(io::Error::last_os_error());
554 }
555 result?;
556 self.0.sync_all()
557 }
558 }
559
560 #[cfg(test)]
561 mod tests {
562 use super::*;
563 use crate::tools::github::GithubTool;
564 use crate::tools::spec::{ApprovalRequirement, ToolSpec};
565
566 struct Fixture {
567 prior: Option<PathBuf>,
568 tmp: tempfile::TempDir,
569 _guard: std::sync::MutexGuard<'static, ()>,
570 }
571 impl Fixture {
572 fn new() -> Self {
573 let guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
574 .lock()
575 .unwrap_or_else(|e| e.into_inner());
576 let tmp = tempfile::tempdir().unwrap();
577 let prior = crate::artifacts::set_test_artifact_sessions_root(Some(
578 tmp.path().join("sessions"),
579 ));
580 Self {
581 prior,
582 tmp,
583 _guard: guard,
584 }
585 }
586 fn context(&self, session: &str) -> ToolContext {
587 ToolContext::new(self.tmp.path())
588 .with_state_namespace(session)
589 .with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
590 session.into(),
591 "current-route-model".into(),
592 self.tmp.path().into(),
593 None,
594 vec![],
595 ))
596 }
597 }
598 impl Drop for Fixture {
599 fn drop(&mut self) {
600 crate::artifacts::set_test_artifact_sessions_root(self.prior.take());
601 }
602 }
603
604 fn fields() -> ReportFields {
605 serde_json::from_value(json!({"title":"Tool result was lost", "expected":"The agent receives the result", "actual":"No result reached the agent", "impact":"The task needed a retry", "steps":["Request the tool", "Wait for completion"], "observed":["The tool completed but no result arrived"], "inferred":["A Runtime event may have been lost"], "related_issues":[123]})).unwrap()
606 }
607
608 #[tokio::test]
609 async fn draft_read_and_revision_persist_without_task_or_github_service() {
610 let fixture = Fixture::new();
611 let tool = GithubTool::new("github");
612 let context = fixture.context("session-a");
613 let input = json!({"action":"report_draft", "report":fields()});
614 assert_eq!(
615 tool.approval_requirement_for(&input),
616 ApprovalRequirement::Auto
617 );
618 assert!(!tool.is_read_only_for(&input));
619 let first = tool.execute(input.clone(), &context).await.unwrap();
620 let repeated = tool.execute(input, &context).await.unwrap();
621 assert_eq!(first.content, repeated.content);
622 let payload: Value = serde_json::from_str(&first.content).unwrap();
623 let id = payload["report_id"].as_str().unwrap();
624 let report = load("session-a", id).unwrap();
625 assert_eq!(report.model, "current-route-model");
626 assert_eq!(report.render_review(), payload["review"]);
627 assert_eq!(payload["publication"], "unavailable");
628 assert!(
629 payload["review"]
630 .as_str()
631 .unwrap()
632 .contains("not verified or searched")
633 );
634 let fresh_context = fixture.context("session-a");
635 let read = GithubTool::read_only("github")
636 .execute(
637 json!({"action":"report_read", "report_id":id}),
638 &fresh_context,
639 )
640 .await
641 .unwrap();
642 assert_eq!(read.content, first.content);
643 assert!(load("session-b", id).is_err());
644 let mut changed = fields();
645 changed.impact = "The task remains blocked".into();
646 let revision =
647 create("session-a", "current-route-model", changed, Some(id.into())).unwrap();
648 assert_ne!(revision.id, id);
649 assert_eq!(revision.revises.as_deref(), Some(id));
650 assert_eq!(
651 load("session-a", id).unwrap().fields.impact,
652 fields().impact
653 );
654 assert!(
655 tool.execute(
656 json!({"action":"report_submit", "report_id":id, "approved":true}),
657 &context
658 )
659 .await
660 .is_err()
661 );
662 assert!(
663 GithubTool::read_only("github")
664 .execute(
665 json!({"action":"report_draft", "report":fields()}),
666 &context
667 )
668 .await
669 .is_err()
670 );
671 }
672
673 #[test]
674 fn disclosure_is_applied_before_persistence_and_review() {
675 let _fixture = Fixture::new();
676 let mut data = fields();
677 data.actual = "Authorization:\nBearer\tfixture-credential-value /Users/private-owner/project/file https://alice:fixture-url-secret@example.invalid/private Received \"sk-fixture12345\" (ghp_fixture12345) 'xai-fixture12345' and **sk-fixture67890**; the user's task failed.".into();
678 let report = create("session-a", "model", data, None).unwrap();
679 let bytes = std::fs::read(
680 directory_path("session-a", false)
681 .unwrap()
682 .join(format!("{}.json", report.id)),
683 )
684 .unwrap();
685 for text in [
686 String::from_utf8(bytes).unwrap(),
687 report.render_review(),
688 load("session-a", &report.id).unwrap().render_review(),
689 ] {
690 for secret in [
691 "fixture-credential-value",
692 "private-owner",
693 "fixture-url-secret",
694 "example.invalid",
695 "sk-fixture12345",
696 "ghp_fixture12345",
697 "xai-fixture12345",
698 "sk-fixture67890",
699 ] {
700 assert!(!text.contains(secret), "retained {secret}");
701 }
702 }
703 assert!(report.redactions.contains("secret"));
704 assert!(report.redactions.contains("absolute_path"));
705 assert!(report.redactions.contains("url"));
706 assert!(report.fields.actual.contains("the user's task failed."));
707 }
708
709 #[tokio::test]
710 async fn malformed_or_contextless_drafts_do_not_create_artifacts() {
711 let fixture = Fixture::new();
712 let context = fixture.context("session-a");
713 for bad in [
714 "`sk-fixture12345`",
715 "See [log](/private/fixture/folder)",
716 r"https:\/\/alice:qqq@example.invalid",
717 "\u{202e}hidden",
718 "",
719 &"x".repeat(7000),
720 ] {
721 let mut data = fields();
722 data.actual = bad.into();
723 assert!(create("session-a", "model", data, None).is_err());
724 }
725 let tool = GithubTool::new("github");
726 let mut input = json!({"action":"report_draft", "report":fields()});
727 input["approved"] = json!(true);
728 assert!(tool.execute(input, &context).await.is_err());
729 assert!(
730 tool.execute(
731 json!({"action":"report_draft", "report":fields()}),
732 &ToolContext::new(fixture.tmp.path())
733 )
734 .await
735 .is_err()
736 );
737 assert!(!fixture.tmp.path().join("sessions").exists());
738 }
739
740 #[test]
741 fn corruption_and_forged_handles_fail_without_echoing_payloads() {
742 let _fixture = Fixture::new();
743 let report = create("session-a", "model", fields(), None).unwrap();
744 for id in ["../../secret", "/private/secret", "cwreport_deadbeef"] {
745 assert!(load("session-a", id).is_err());
746 }
747 let path = directory_path("session-a", false)
748 .unwrap()
749 .join(format!("{}.json", report.id));
750 std::fs::write(&path, b"private-corrupt-payload").unwrap();
751 let error = load("session-a", &report.id).unwrap_err().to_string();
752 assert!(!error.contains("private-corrupt-payload"));
753 assert!(!error.contains(path.to_str().unwrap()));
754 }
755
756 #[cfg(unix)]
757 #[test]
758 fn symlink_hardlink_fifo_and_parent_swap_cannot_redirect_artifacts() {
759 use std::os::unix::ffi::OsStrExt;
760 use std::os::unix::fs::symlink;
761 let fixture = Fixture::new();
762 let report = create("session-a", "model", fields(), None).unwrap();
763 let path = directory_path("session-a", false).unwrap();
764 let leaf = path.join(format!("{}.json", report.id));
765 let original = std::fs::read(&leaf).unwrap();
766 use std::os::unix::fs::PermissionsExt;
767 std::fs::set_permissions(&leaf, std::fs::Permissions::from_mode(0o644)).unwrap();
768 assert!(load("session-a", &report.id).is_err());
769 std::fs::set_permissions(&leaf, std::fs::Permissions::from_mode(0o600)).unwrap();
770 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
771 assert!(load("session-a", &report.id).is_err());
772 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
773 assert!(load("session-a", &report.id).is_ok());
774 std::fs::remove_file(&leaf).unwrap();
775 let outside = fixture.tmp.path().join("outside.json");
776 std::fs::write(&outside, &original).unwrap();
777 symlink(&outside, &leaf).unwrap();
778 assert!(load("session-a", &report.id).is_err());
779 std::fs::remove_file(&leaf).unwrap();
780 std::fs::hard_link(&outside, &leaf).unwrap();
781 assert!(load("session-a", &report.id).is_err());
782 std::fs::remove_file(&leaf).unwrap();
783 let cpath = std::ffi::CString::new(leaf.as_os_str().as_bytes()).unwrap();
784 // SAFETY: test-owned path; the read must reject without blocking.
785 assert_eq!(unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) }, 0);
786 assert!(load("session-a", &report.id).is_err());
787 std::fs::remove_file(&leaf).unwrap();
788 let anchor = AnchoredDirectory::open(&path, false).unwrap();
789 let moved = path.with_file_name("moved-reports");
790 std::fs::rename(&path, &moved).unwrap();
791 let unrelated = fixture.tmp.path().join("unrelated");
792 std::fs::create_dir(&unrelated).unwrap();
793 symlink(&unrelated, &path).unwrap();
794 anchor.publish("pinned.json", b"safe").unwrap();
795 assert_eq!(std::fs::read(moved.join("pinned.json")).unwrap(), b"safe");
796 assert!(!unrelated.join("pinned.json").exists());
797 assert!(load("session-a", &report.id).is_err());
798 assert!(create("session-a", "model", fields(), None).is_err());
799 }
800 }
801
802 #[cfg(windows)]
803 struct AnchoredDirectory {
804 path: PathBuf,
805 _parents: Vec<File>,
806 }
807
808 #[cfg(windows)]
809 impl AnchoredDirectory {
810 fn open(path: &Path, create: bool) -> io::Result<Self> {
811 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
812 use std::path::Component;
813 use windows_sys::Win32::Storage::FileSystem::{
814 FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
815 FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, WRITE_DAC, WRITE_OWNER,
816 };
817 let mut current = PathBuf::new();
818 let mut parents = Vec::new();
819 for component in path.components() {
820 current.push(component.as_os_str());
821 if matches!(component, Component::Prefix(_) | Component::RootDir) {
822 continue;
823 }
824 if !matches!(component, Component::Normal(_)) {
825 return Err(io::ErrorKind::InvalidInput.into());
826 }
827 if create {
828 match std::fs::create_dir(&current) {
829 Ok(()) => (),
830 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => (),
831 Err(err) => return Err(err),
832 }
833 }
834 // Retain every parent without delete sharing. Reparse points are
835 // opened as objects then rejected, never traversed to a child.
836 let file = std::fs::OpenOptions::new()
837 .read(true)
838 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
839 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
840 .open(&current)?;
841 let metadata = file.metadata()?;
842 if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
843 {
844 return Err(io::ErrorKind::PermissionDenied.into());
845 }
846 parents.push(file);
847 }
848 if create {
849 let secured = std::fs::OpenOptions::new()
850 .access_mode(FILE_GENERIC_READ | WRITE_DAC | WRITE_OWNER)
851 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
852 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
853 .open(path)?;
854 windows_acl::secure_windows_owner_only_handle(&secured, true)
855 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
856 parents.push(secured);
857 }
858 windows_acl::verify_windows_owner_only_handle(
859 parents.last().ok_or(io::ErrorKind::InvalidInput)?,
860 )
861 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
862 Ok(Self {
863 path: path.into(),
864 _parents: parents,
865 })
866 }
867
868 fn read(&self, name: &str) -> io::Result<Vec<u8>> {
869 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
870 use std::os::windows::io::AsRawHandle;
871 use windows_sys::Win32::Storage::FileSystem::{
872 BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT,
873 FILE_SHARE_READ, GetFileInformationByHandle,
874 };
875 let file = std::fs::OpenOptions::new()
876 .read(true)
877 .share_mode(FILE_SHARE_READ)
878 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
879 .open(self.path.join(name))?;
880 if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
881 return Err(io::ErrorKind::PermissionDenied.into());
882 }
883 // SAFETY: the opened handle and output structure remain valid; inspect
884 // this exact object rather than reopening its mutable path.
885 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
886 if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut info) } == 0 {
887 return Err(io::Error::last_os_error());
888 }
889 if info.nNumberOfLinks != 1 {
890 return Err(io::ErrorKind::PermissionDenied.into());
891 }
892 windows_acl::verify_windows_owner_only_handle(&file)
893 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
894 bounded_read(file)
895 }
896
897 fn publish(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
898 let mut file = tempfile::NamedTempFile::new_in(&self.path)?;
899 let secured = windows_acl::reopen_windows_file_for_owner_security(file.as_file())?;
900 windows_acl::secure_windows_owner_only_handle(&secured, false)
901 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
902 windows_acl::verify_windows_owner_only_handle(&secured)
903 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
904 file.write_all(bytes)?;
905 file.as_file().sync_all()?;
906 let persisted = file
907 .persist_noclobber(self.path.join(name))
908 .map_err(|err| err.error)?;
909 windows_acl::verify_windows_owner_only_handle(&persisted)
910 .map_err(|_| io::Error::from(io::ErrorKind::PermissionDenied))?;
911 Ok(())
912 }
913 }
914
915 #[cfg(not(any(unix, windows)))]
916 struct AnchoredDirectory;
917 #[cfg(not(any(unix, windows)))]
918 impl AnchoredDirectory {
919 fn open(_: &Path, _: bool) -> io::Result<Self> {
920 Err(io::ErrorKind::Unsupported.into())
921 }
922 fn read(&self, _: &str) -> io::Result<Vec<u8>> {
923 Err(io::ErrorKind::Unsupported.into())
924 }
925 fn publish(&self, _: &str, _: &[u8]) -> io::Result<()> {
926 Err(io::ErrorKind::Unsupported.into())
927 }
928 }
929
930 // Same-handle private ACL operations follow config/xai_credentials.rs.
931 #[cfg(windows)]
932 mod windows_acl {
933 #[cfg(windows)]
934 use anyhow::{Context, Result, bail};
935 #[cfg(windows)]
936 use std::fs::File;
937
938 /// Reopen the exact temporary object for ACL mutation before payload writes.
939 /// This deliberately allows DELETE sharing so persist_noclobber can rename it.
940 #[cfg(windows)]
941 pub(super) fn reopen_windows_file_for_owner_security(file: &File) -> std::io::Result<File> {
942 use std::os::windows::fs::MetadataExt as _;
943 use std::os::windows::io::{AsRawHandle as _, FromRawHandle as _};
944 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
945 use windows_sys::Win32::Storage::FileSystem::{
946 DELETE, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ,
947 FILE_GENERIC_WRITE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, ReOpenFile,
948 WRITE_DAC, WRITE_OWNER,
949 };
950 // SAFETY: ReOpenFile derives a newly owned handle from the live file object.
951 let handle = unsafe {
952 ReOpenFile(
953 file.as_raw_handle(),
954 FILE_GENERIC_READ | FILE_GENERIC_WRITE | WRITE_DAC | WRITE_OWNER | DELETE,
955 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
956 FILE_FLAG_OPEN_REPARSE_POINT,
957 )
958 };
959 if handle == INVALID_HANDLE_VALUE {
960 return Err(std::io::Error::last_os_error());
961 }
962 // SAFETY: the successful handle is newly owned and closed by File.
963 let reopened = unsafe { File::from_raw_handle(handle) };
964 let metadata = reopened.metadata()?;
965 if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
966 return Err(std::io::ErrorKind::PermissionDenied.into());
967 }
968 Ok(reopened)
969 }
970
971 #[cfg(windows)]
972 pub(super) fn secure_windows_owner_only_handle(
973 file: &File,
974 inherit_to_children: bool,
975 ) -> Result<()> {
976 use std::os::windows::io::AsRawHandle as _;
977 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
978 use windows_sys::Win32::Security::Authorization::{
979 EXPLICIT_ACCESS_W, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetSecurityInfo,
980 TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
981 };
982 use windows_sys::Win32::Security::{
983 DACL_SECURITY_INFORMATION, NO_INHERITANCE, OWNER_SECURITY_INFORMATION,
984 PROTECTED_DACL_SECURITY_INFORMATION, SUB_CONTAINERS_AND_OBJECTS_INHERIT,
985 };
986 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
987
988 let user = CurrentWindowsUser::open()?;
989 let entry = EXPLICIT_ACCESS_W {
990 grfAccessPermissions: FILE_ALL_ACCESS,
991 grfAccessMode: SET_ACCESS,
992 grfInheritance: if inherit_to_children {
993 SUB_CONTAINERS_AND_OBJECTS_INHERIT
994 } else {
995 NO_INHERITANCE
996 },
997 Trustee: TRUSTEE_W {
998 pMultipleTrustee: std::ptr::null_mut(),
999 MultipleTrusteeOperation: 0,
1000 TrusteeForm: TRUSTEE_IS_SID,
1001 TrusteeType: TRUSTEE_IS_USER,
1002 ptstrName: user.sid().cast::<u16>(),
1003 },
1004 };
1005 let mut acl = std::ptr::null_mut();
1006 // SAFETY: `entry` and the returned ACL remain live through the following
1007 // handle-relative security update.
1008 let result = unsafe { SetEntriesInAclW(1, &raw const entry, std::ptr::null(), &mut acl) };
1009 if result != ERROR_SUCCESS {
1010 return Err(std::io::Error::from_raw_os_error(result as i32))
1011 .context("building a current-user-only DACL for Codewhale issue-report storage");
1012 }
1013 let _acl = WindowsLocalAllocation(acl.cast());
1014 // SAFETY: the file handle remains owned by `file`, and the ACL remains
1015 // allocated for the duration of the call. The owner and protected DACL are
1016 // committed together so the verifier never observes a half-secured file.
1017 let result = unsafe {
1018 SetSecurityInfo(
1019 file.as_raw_handle(),
1020 SE_FILE_OBJECT,
1021 OWNER_SECURITY_INFORMATION
1022 | DACL_SECURITY_INFORMATION
1023 | PROTECTED_DACL_SECURITY_INFORMATION,
1024 user.sid(),
1025 std::ptr::null_mut(),
1026 acl,
1027 std::ptr::null(),
1028 )
1029 };
1030 if result != ERROR_SUCCESS {
1031 return Err(std::io::Error::from_raw_os_error(result as i32))
1032 .context("applying a current-user-only DACL to Codewhale issue-report storage");
1033 }
1034 Ok(())
1035 }
1036
1037 #[cfg(windows)]
1038 pub(super) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
1039 use std::os::windows::io::AsRawHandle as _;
1040 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
1041 use windows_sys::Win32::Security::Authorization::{
1042 EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
1043 SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
1044 };
1045 use windows_sys::Win32::Security::{
1046 ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION,
1047 PSECURITY_DESCRIPTOR, PSID,
1048 };
1049 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1050
1051 let user = CurrentWindowsUser::open()?;
1052 let mut owner: PSID = std::ptr::null_mut();
1053 let mut dacl: *mut ACL = std::ptr::null_mut();
1054 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
1055 // SAFETY: the handle remains valid and all output pointers are writable.
1056 let result = unsafe {
1057 GetSecurityInfo(
1058 file.as_raw_handle(),
1059 SE_FILE_OBJECT,
1060 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1061 &mut owner,
1062 std::ptr::null_mut(),
1063 &mut dacl,
1064 std::ptr::null_mut(),
1065 &mut descriptor,
1066 )
1067 };
1068 if result != ERROR_SUCCESS {
1069 return Err(std::io::Error::from_raw_os_error(result as i32))
1070 .context("reading Codewhale issue-report security descriptor");
1071 }
1072 let _descriptor = WindowsLocalAllocation(descriptor.cast());
1073 anyhow::ensure!(
1074 !owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0,
1075 "Codewhale issue-report storage owner is not the current user"
1076 );
1077 anyhow::ensure!(
1078 !dacl.is_null(),
1079 "Codewhale issue-report storage must have an owner-only DACL"
1080 );
1081 let mut count = 0;
1082 let mut entries: *mut EXPLICIT_ACCESS_W = std::ptr::null_mut();
1083 // SAFETY: `dacl` belongs to the live descriptor; Windows allocates the
1084 // returned entry array, released by the guard below.
1085 let result = unsafe { GetExplicitEntriesFromAclW(dacl, &mut count, &mut entries) };
1086 if result != ERROR_SUCCESS {
1087 return Err(std::io::Error::from_raw_os_error(result as i32))
1088 .context("reading Codewhale issue-report DACL entries");
1089 }
1090 let _entries = WindowsLocalAllocation(entries.cast());
1091 anyhow::ensure!(
1092 count == 1 && !entries.is_null(),
1093 "Codewhale issue-report DACL must grant only one user"
1094 );
1095 // SAFETY: `count == 1` proves the first returned entry is initialized.
1096 let entry = unsafe { &*entries };
1097 let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
1098 anyhow::ensure!(
1099 entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
1100 && !trustee_sid.is_null()
1101 && unsafe { EqualSid(trustee_sid, user.sid()) } != 0
1102 && matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
1103 && entry.grfAccessPermissions == FILE_ALL_ACCESS,
1104 "Codewhale issue-report DACL is not current-user-only"
1105 );
1106 Ok(())
1107 }
1108
1109 #[cfg(windows)]
1110 struct CurrentWindowsUser {
1111 token: windows_sys::Win32::Foundation::HANDLE,
1112 token_info: Vec<usize>,
1113 }
1114
1115 #[cfg(windows)]
1116 impl CurrentWindowsUser {
1117 fn open() -> Result<Self> {
1118 use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE};
1119 use windows_sys::Win32::Security::{
1120 GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
1121 };
1122 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
1123
1124 let mut token: HANDLE = std::ptr::null_mut();
1125 // SAFETY: the pseudo-process handle is valid and `token` is writable.
1126 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
1127 return Err(std::io::Error::last_os_error())
1128 .context("opening current Windows user token");
1129 }
1130 let mut needed = 0;
1131 // SAFETY: a null buffer/zero length asks for the required size.
1132 let _ = unsafe {
1133 GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed)
1134 };
1135 if needed == 0 {
1136 let error = std::io::Error::from_raw_os_error(unsafe { GetLastError() } as i32);
1137 // SAFETY: the token is owned on this error path.
1138 unsafe { CloseHandle(token) };
1139 return Err(error).context("sizing current Windows user token information");
1140 }
1141 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
1142 let mut token_info = vec![0usize; words];
1143 // SAFETY: the aligned buffer contains at least `needed` writable bytes.
1144 if unsafe {
1145 GetTokenInformation(
1146 token,
1147 TokenUser,
1148 token_info.as_mut_ptr().cast(),
1149 needed,
1150 &mut needed,
1151 )
1152 } == 0
1153 {
1154 let error = std::io::Error::last_os_error();
1155 // SAFETY: the token is owned on this error path.
1156 unsafe { CloseHandle(token) };
1157 return Err(error).context("reading current Windows user token information");
1158 }
1159 let user = unsafe { &*token_info.as_ptr().cast::<TOKEN_USER>() };
1160 if user.User.Sid.is_null() {
1161 // SAFETY: the token is owned on this error path.
1162 unsafe { CloseHandle(token) };
1163 bail!("current Windows user token has no SID");
1164 }
1165 Ok(Self { token, token_info })
1166 }
1167
1168 fn sid(&self) -> windows_sys::Win32::Security::PSID {
1169 use windows_sys::Win32::Security::TOKEN_USER;
1170 // SAFETY: the aligned token buffer remains owned by `self`.
1171 unsafe { (*self.token_info.as_ptr().cast::<TOKEN_USER>()).User.Sid }
1172 }
1173 }
1174
1175 #[cfg(windows)]
1176 impl Drop for CurrentWindowsUser {
1177 fn drop(&mut self) {
1178 // SAFETY: `token` is owned by this guard and closed exactly once.
1179 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.token) };
1180 }
1181 }
1182
1183 #[cfg(windows)]
1184 struct WindowsLocalAllocation(*mut core::ffi::c_void);
1185
1186 #[cfg(windows)]
1187 impl Drop for WindowsLocalAllocation {
1188 fn drop(&mut self) {
1189 if !self.0.is_null() {
1190 // SAFETY: Windows allocated this block for a LocalFree caller.
1191 unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) };
1192 }
1193 }
1194 }
1195 }
1196
1196 lines RUST