netget/scripting/
process_io.rs1use std::io;
4use tokio::io::{AsyncRead, AsyncReadExt};
5
6pub const MAX_STDOUT_BYTES: usize = 8 * 1024 * 1024;
7pub const MAX_STDERR_BYTES: usize = 1024 * 1024;
8
9pub struct ProcessGroup {
13 #[cfg(unix)]
14 pid: Option<u32>,
15 #[cfg(windows)]
16 job: std::os::windows::io::OwnedHandle,
17}
18
19pub const STRIPPED_ENV: &[&str] = &[
37 "NETGET_API_KEY",
38 "OPENAI_API_KEY",
39 "ANTHROPIC_API_KEY",
40 "NETGET_MCP_TOKEN",
41 "AWS_SECRET_ACCESS_KEY",
42 "AWS_SESSION_TOKEN",
43 "GITHUB_TOKEN",
44 "GH_TOKEN",
45 "NPM_TOKEN",
46];
47
48pub const SECRET_NAME_PARTS: &[&str] = &[
50 "SECRET",
51 "PASSWORD",
52 "PASSWD",
53 "API_KEY",
54 "APIKEY",
55 "ACCESS_TOKEN",
56 "AUTH_TOKEN",
57 "PRIVATE_KEY",
58 "CREDENTIAL",
59];
60
61pub const SECRET_NAME_SUFFIXES: &[&str] =
65 &["_TOKEN", "_SECRET", "_PASSWORD", "_API_KEY", "_ACCESS_KEY"];
66
67pub fn env_is_stripped(name: &str) -> bool {
69 let upper = name.to_ascii_uppercase();
70 STRIPPED_ENV.contains(&upper.as_str())
71 || SECRET_NAME_PARTS.iter().any(|part| upper.contains(part))
72 || SECRET_NAME_SUFFIXES
73 .iter()
74 .any(|suffix| upper.ends_with(suffix))
75}
76
77impl ProcessGroup {
78 pub fn configure(command: &mut tokio::process::Command) {
82 #[cfg(unix)]
83 command.process_group(0);
84 for (name, _) in std::env::vars_os() {
85 if name.to_str().is_some_and(env_is_stripped) {
86 command.env_remove(&name);
87 }
88 }
89 }
90
91 pub fn new(child: &tokio::process::Child) -> io::Result<Self> {
92 #[cfg(windows)]
93 {
94 use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
95 use windows_sys::Win32::System::JobObjects::*;
96 let raw = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
97 if raw.is_null() {
98 return Err(io::Error::last_os_error());
99 }
100 let job = unsafe { OwnedHandle::from_raw_handle(raw) };
101 let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
102 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
103 let configured = unsafe {
104 SetInformationJobObject(
105 raw,
106 JobObjectExtendedLimitInformation,
107 (&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
108 std::mem::size_of_val(&limits) as u32,
109 )
110 };
111 if configured == 0 {
112 return Err(io::Error::last_os_error());
113 }
114 let process = child
115 .raw_handle()
116 .ok_or_else(|| io::Error::other("script process already exited"))?;
117 if unsafe { AssignProcessToJobObject(job.as_raw_handle(), process) } == 0 {
118 return Err(io::Error::last_os_error());
119 }
120 Ok(Self { job })
121 }
122 #[cfg(not(windows))]
123 {
124 let _ = child;
125 Ok(Self {
126 #[cfg(unix)]
127 pid: child.id(),
128 })
129 }
130 }
131
132 pub fn kill(&self) {
133 #[cfg(windows)]
134 {
135 use std::os::windows::io::AsRawHandle;
136 unsafe {
137 windows_sys::Win32::System::JobObjects::TerminateJobObject(
138 self.job.as_raw_handle(),
139 1,
140 );
141 }
142 }
143 #[cfg(unix)]
144 if let Some(pid) = self.pid.and_then(|pid| i32::try_from(pid).ok()) {
145 unsafe {
147 libc::kill(-pid, libc::SIGKILL);
148 }
149 }
150 }
151}
152
153impl Drop for ProcessGroup {
154 fn drop(&mut self) {
155 self.kill();
156 }
157}
158
159pub async fn read_bounded<R: AsyncRead + Unpin>(
161 mut reader: R,
162 limit: usize,
163) -> io::Result<Vec<u8>> {
164 let mut output = Vec::new();
165 let mut chunk = vec![0; 8192];
166 loop {
167 let n = reader.read(&mut chunk).await?;
168 if n == 0 {
169 return Ok(output);
170 }
171 if n > limit.saturating_sub(output.len()) {
172 return Err(io::Error::new(
173 io::ErrorKind::InvalidData,
174 format!("script output exceeds the {limit}-byte cap"),
175 ));
176 }
177 output.extend_from_slice(&chunk[..n]);
178 }
179}
180
181pub(crate) async fn read_line_bounded<R: tokio::io::AsyncBufRead + Unpin>(
182 reader: &mut R,
183 limit: usize,
184) -> io::Result<String> {
185 use tokio::io::AsyncBufReadExt;
186 let mut line = Vec::new();
187 loop {
188 let available = reader.fill_buf().await?;
189 if available.is_empty() {
190 return Err(io::Error::new(
191 io::ErrorKind::UnexpectedEof,
192 "resident script exited before a complete response",
193 ));
194 }
195 let newline = available.iter().position(|&byte| byte == b'\n');
196 let take = newline.map_or(available.len(), |n| n + 1);
197 if take > limit.saturating_sub(line.len()) {
198 return Err(io::Error::new(
199 io::ErrorKind::InvalidData,
200 "resident script response exceeds the 8 MiB cap",
201 ));
202 }
203 line.extend_from_slice(&available[..take]);
204 reader.consume(take);
205 if newline.is_some() {
206 return String::from_utf8(line)
207 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error));
208 }
209 }
210}
211
212pub(crate) struct ScriptDirectory(std::path::PathBuf);
214
215impl ScriptDirectory {
216 pub(crate) fn new() -> io::Result<Self> {
217 let path = std::env::temp_dir().join(format!("netget-script-{}", uuid::Uuid::new_v4()));
218 let mut builder = std::fs::DirBuilder::new();
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::DirBuilderExt;
222 builder.mode(0o700);
223 }
224 builder.create(&path)?;
225 Ok(Self(path))
226 }
227
228 pub(crate) fn script_path(&self) -> std::path::PathBuf {
229 self.0.join("main.go")
230 }
231}
232
233impl Drop for ScriptDirectory {
234 fn drop(&mut self) {
235 let _ = std::fs::remove_dir_all(&self.0);
236 }
237}