1use crate::types::SessionConfig;
2use std::path::{Path, PathBuf};
3
4const PROFILE_PREFIX: &str = "afpsql-readonly-";
5const PROFILE_MAX_BYTES: u64 = 65_536;
6
7pub fn validate_raw_args(args: &[String]) -> Result<(), String> {
8 validate_raw_args_for_profile(args, false)
9}
10
11pub fn validate_raw_args_for_profile(args: &[String], locked_profile: bool) -> Result<(), String> {
12 if !locked_profile {
13 return Ok(());
14 }
15 reject_stream_redirect(args)?;
16 let mut index = 1;
17 while index < args.len() {
18 let arg = &args[index];
19 if let Some((flag, value)) = arg.split_once('=') {
20 if locked_profile && is_connection_or_transport_flag(flag) {
21 return Err(locked_profile_override_error(flag));
22 }
23 if is_value_flag(flag) {
24 validate_raw_value(flag, value)?;
25 }
26 index += 1;
27 continue;
28 }
29 if locked_profile && is_connection_or_transport_flag(arg) {
30 return Err(locked_profile_override_error(arg));
31 }
32 if is_opaque_value_flag(arg) {
33 index += 2;
34 continue;
35 }
36 if is_value_flag(arg) {
37 let value = args
38 .get(index + 1)
39 .ok_or_else(|| format!("{arg} requires a value"))?;
40 validate_raw_value(arg, value)?;
41 index += 2;
42 continue;
43 }
44 index += 1;
45 }
46 Ok(())
47}
48
49fn locked_profile_override_error(flag: &str) -> String {
50 format!("{flag} cannot override an administrator-locked afpsql-readonly profile")
51}
52
53fn reject_stream_redirect(args: &[String]) -> Result<(), String> {
63 match agent_first_data::stream_redirect::config_from_raw_args(args.iter().cloned()) {
64 Ok(None) => Ok(()),
65 _ => Err(
66 "--stdout-file and --stderr-file are unavailable in afpsql-readonly because they create or truncate local files"
67 .to_string(),
68 ),
69 }
70}
71
72fn is_connection_or_transport_flag(flag: &str) -> bool {
73 matches!(
74 flag,
75 "--dsn"
76 | "--conninfo"
77 | "--host"
78 | "--port"
79 | "--user"
80 | "--dbname"
81 | "--password"
82 | "--ssh"
83 | "--ssh-via"
84 | "--ssh-option"
85 | "--ssh-remote-socket"
86 | "--ssh-sudo-user"
87 | "--container-docker-name"
88 | "--container-docker-user"
89 | "--container-docker-context"
90 | "--container-docker-runtime"
91 | "--container-podman-name"
92 | "--container-podman-user"
93 | "--container-podman-runtime"
94 | "--container-nerdctl-name"
95 | "--container-nerdctl-user"
96 | "--container-nerdctl-runtime"
97 | "--container-compose-service"
98 | "--container-compose-user"
99 | "--container-compose-file"
100 | "--container-compose-project"
101 | "--container-compose-runtime"
102 | "--container-kubectl-pod"
103 | "--container-kubectl-container"
104 | "--container-kubectl-namespace"
105 | "--container-kubectl-context"
106 | "--container-kubectl-runtime"
107 )
108}
109
110pub fn locked_profile_name(executable: &str) -> Result<Option<String>, String> {
111 let file_name = Path::new(executable)
112 .file_stem()
113 .and_then(|value| value.to_str())
114 .unwrap_or_default();
115 let Some(name) = file_name.strip_prefix(PROFILE_PREFIX) else {
116 return Ok(None);
117 };
118 if name.is_empty()
119 || !name
120 .bytes()
121 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
122 {
123 return Err(
124 "locked readonly profile name may contain only ASCII letters, digits, `-`, and `_`"
125 .to_string(),
126 );
127 }
128 Ok(Some(name.to_string()))
129}
130
131pub fn locked_profile_path(name: &str) -> PathBuf {
132 Path::new("/etc/afpsql/readonly-profiles").join(format!("{name}.json"))
133}
134
135pub fn load_locked_profile(name: &str) -> Result<SessionConfig, String> {
136 let path = locked_profile_path(name);
137 let metadata = std::fs::metadata(&path).map_err(|error| {
138 format!(
139 "cannot read locked readonly profile {}: {error}",
140 path.display()
141 )
142 })?;
143 if !metadata.is_file() || metadata.len() > PROFILE_MAX_BYTES {
144 return Err(format!(
145 "locked readonly profile {} must be a regular file no larger than {PROFILE_MAX_BYTES} bytes",
146 path.display()
147 ));
148 }
149 validate_profile_permissions(&path, &metadata)?;
150 let bytes = std::fs::read(&path).map_err(|error| {
151 format!(
152 "cannot read locked readonly profile {}: {error}",
153 path.display()
154 )
155 })?;
156 let session: SessionConfig = serde_json::from_slice(&bytes).map_err(|error| {
157 format!(
158 "invalid locked readonly profile {}: {error}",
159 path.display()
160 )
161 })?;
162 Ok(session)
163}
164
165#[cfg(unix)]
166fn validate_profile_permissions(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> {
167 use std::os::unix::fs::MetadataExt;
168 if metadata.uid() != 0 || metadata.mode() & 0o022 != 0 {
169 return Err(format!(
170 "locked readonly profile {} must be owned by root and not writable by group or others",
171 path.display()
172 ));
173 }
174 Ok(())
175}
176
177#[cfg(not(unix))]
178fn validate_profile_permissions(path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> {
179 Err(format!(
180 "locked readonly profiles require Unix ownership checks; unsupported for {}",
181 path.display()
182 ))
183}
184
185fn is_opaque_value_flag(flag: &str) -> bool {
186 matches!(
187 flag,
188 "--sql"
189 | "--param"
190 | "--dsn"
191 | "--conninfo"
192 | "--password"
193 | "--host"
194 | "--port"
195 | "--user"
196 | "--dbname"
197 | "--ssh"
198 | "--ssh-via"
199 | "--ssh-option"
200 | "--ssh-remote-socket"
201 | "--ssh-sudo-user"
202 | "--container-docker-name"
203 | "--container-docker-user"
204 | "--container-docker-context"
205 | "--container-podman-name"
206 | "--container-podman-user"
207 | "--container-nerdctl-name"
208 | "--container-nerdctl-user"
209 | "--container-compose-service"
210 | "--container-compose-user"
211 | "--container-compose-file"
212 | "--container-compose-project"
213 | "--container-kubectl-pod"
214 | "--container-kubectl-container"
215 | "--container-kubectl-namespace"
216 | "--container-kubectl-context"
217 | "--permission"
218 | "--explain"
219 | "--mode"
220 | "--output"
221 | "--log"
222 | "--batch-rows"
223 | "--batch-bytes"
224 | "--statement-timeout-ms"
225 | "--lock-timeout-ms"
226 | "--inline-max-rows"
227 | "--inline-max-bytes"
228 | "--command"
229 | "--set"
230 | "-c"
231 | "-v"
232 | "-h"
233 | "-p"
234 | "-U"
235 | "-d"
236 )
237}
238
239fn is_value_flag(flag: &str) -> bool {
240 matches!(
241 flag,
242 "--stdout-file" | "--stderr-file" | "--sql-file" | "--file" | "-f"
243 ) || is_container_runtime_flag(flag)
244}
245
246fn is_container_runtime_flag(flag: &str) -> bool {
249 matches!(
250 flag,
251 "--container-docker-runtime"
252 | "--container-podman-runtime"
253 | "--container-nerdctl-runtime"
254 | "--container-compose-runtime"
255 | "--container-kubectl-runtime"
256 )
257}
258
259fn validate_raw_value(flag: &str, value: &str) -> Result<(), String> {
260 match flag {
261 "--stdout-file" | "--stderr-file" => Err(format!(
262 "{flag} is unavailable in afpsql-readonly because it can create or truncate local files"
263 )),
264 "--sql-file" | "--file" | "-f" if value != "-" => Err(format!(
265 "{flag} only accepts `-` in afpsql-readonly; use inline SQL or stdin"
266 )),
267 _ if is_container_runtime_flag(flag) => Err(format!(
268 "{flag} is unavailable in afpsql-readonly; each container driver family runs its own fixed runtime"
269 )),
270 _ => Ok(()),
271 }
272}
273
274pub fn validate_session(session: &SessionConfig) -> Result<(), String> {
275 validate_session_with_trust(session, false)
276}
277
278pub fn validate_session_with_trust(
279 session: &SessionConfig,
280 trusted_profile: bool,
281) -> Result<(), String> {
282 if session.container.runtime_override().is_some() && !trusted_profile {
283 return Err(
284 "custom container runtime is unavailable in afpsql-readonly; use a container driver family with its fixed runtime"
285 .to_string(),
286 );
287 }
288 for option in &session.ssh.options {
289 let key = option
290 .split_once('=')
291 .map_or(option.as_str(), |(key, _)| key)
292 .trim();
293 if !matches!(
294 key.to_ascii_lowercase().as_str(),
295 "connectionattempts"
296 | "connecttimeout"
297 | "port"
298 | "proxyjump"
299 | "serveralivecountmax"
300 | "serveraliveinterval"
301 | "tcpkeepalive"
302 ) {
303 return Err(format!(
304 "SSH option `{key}` is unavailable in afpsql-readonly; allowed options are ConnectionAttempts, ConnectTimeout, Port, ProxyJump, ServerAliveCountMax, ServerAliveInterval, and TCPKeepAlive"
305 ));
306 }
307 }
308 Ok(())
309}
310
311pub fn validate_sql(sql: &str) -> Result<(), String> {
312 let keywords = leading_keywords(sql, 4);
313 let is_transaction_control = matches!(
314 keywords.first().map(String::as_str),
315 Some("begin" | "commit" | "end" | "rollback" | "abort" | "savepoint" | "release")
316 ) || matches!(keywords.as_slice(), [first, second, ..]
317 if (first == "start" && second == "transaction")
318 || (first == "prepare" && second == "transaction")
319 || (first == "set" && second == "transaction"))
320 || matches!(keywords.as_slice(), [first, second, third, ..]
321 if first == "set"
322 && ((second == "session" && third == "characteristics")
323 || (matches!(second.as_str(), "local" | "session")
324 && third == "transaction")));
325 if is_transaction_control {
326 Err(
327 "transaction control SQL is unavailable in afpsql-readonly; use pipe begin/commit/rollback requests so the readonly state machine remains authoritative"
328 .to_string(),
329 )
330 } else {
331 Ok(())
332 }
333}
334
335fn leading_keywords(sql: &str, limit: usize) -> Vec<String> {
336 let bytes = sql.as_bytes();
337 let mut index = 0;
338 let mut words = Vec::with_capacity(limit);
339 while index < bytes.len() && words.len() < limit {
340 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
341 index += 1;
342 }
343 if bytes.get(index..index + 2) == Some(b"--") {
344 index += 2;
345 while index < bytes.len() && bytes[index] != b'\n' {
346 index += 1;
347 }
348 continue;
349 }
350 if bytes.get(index..index + 2) == Some(b"/*") {
351 index += 2;
352 let mut depth = 1usize;
353 while index < bytes.len() && depth > 0 {
354 if bytes.get(index..index + 2) == Some(b"/*") {
355 depth += 1;
356 index += 2;
357 } else if bytes.get(index..index + 2) == Some(b"*/") {
358 depth -= 1;
359 index += 2;
360 } else {
361 index += 1;
362 }
363 }
364 continue;
365 }
366 let start = index;
367 while index < bytes.len() && (bytes[index].is_ascii_alphabetic() || bytes[index] == b'_') {
368 index += 1;
369 }
370 if start == index {
371 break;
372 }
373 words.push(sql[start..index].to_ascii_lowercase());
374 }
375 words
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::types::{ContainerConfig, SshConfig};
382
383 #[test]
384 fn ordinary_raw_policy_allows_host_capabilities() {
385 for args in [
386 vec!["afpsql-readonly", "--stdout-file", "/tmp/out"],
387 vec!["afpsql-readonly", "--stderr-file=/tmp/err"],
388 vec!["afpsql-readonly", "--sql-file", "/tmp/query.sql"],
389 vec!["afpsql-readonly", "--mode", "psql", "-f", "/tmp/query.sql"],
390 vec!["afpsql-readonly", "--sql", "--stdout-file=/tmp/out"],
394 vec!["afpsql-readonly", "--sql", "--stdout-file", "/tmp/out"],
395 vec!["afpsql-readonly", "--param", "x", "--stderr-file=/tmp/err"],
396 ] {
397 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
398 assert!(validate_raw_args(&args).is_ok(), "rejected {args:?}");
399 }
400 assert!(
401 validate_raw_args(&["afpsql-readonly".to_string(), "--sql-file=-".to_string()]).is_ok()
402 );
403 for args in [
404 vec!["afpsql-readonly", "-c", "--sql-file=/tmp/not-a-flag"],
407 vec![
408 "afpsql-readonly",
409 "--param",
410 "1=--container-docker-runtime=touch",
411 ],
412 ] {
413 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
414 assert!(validate_raw_args(&args).is_ok(), "rejected value {args:?}");
415 }
416 }
417
418 #[test]
419 fn ordinary_raw_policy_allows_arbitrary_explicit_secret_env_names() {
420 for name in ["DATABASE_URL", "AFPSQL_DSN_SECRET", "AWS_SECRET_ACCESS_KEY"] {
421 assert!(
422 validate_raw_args(&["afpsql-readonly".to_string(), format!("--dsn=env:{name}")])
423 .is_ok()
424 );
425 }
426 }
427
428 #[test]
429 fn locked_raw_policy_rejects_host_capabilities_in_any_order() {
430 for prohibited in [
431 vec!["--stdout-file", "/tmp/out"],
432 vec!["--sql-file", "/tmp/query.sql"],
433 vec!["--container-docker-runtime", "custom-runtime"],
434 vec!["--container-kubectl-runtime", "custom-runtime"],
435 vec!["--dsn", "env:AWS_SECRET_ACCESS_KEY"],
436 ] {
437 for args in [
438 [
439 vec!["afpsql-readonly"],
440 prohibited.clone(),
441 vec!["--sql", "select 1"],
442 ]
443 .concat(),
444 [
445 vec!["afpsql-readonly", "--sql", "select 1"],
446 prohibited.clone(),
447 ]
448 .concat(),
449 ] {
450 let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
451 assert!(
452 validate_raw_args_for_profile(&args, true).is_err(),
453 "accepted {args:?}"
454 );
455 }
456 }
457 }
458
459 #[test]
460 fn readonly_session_policy_allows_only_safe_ssh_options_and_fixed_runtimes() {
461 let allowed = SessionConfig {
462 ssh: SshConfig {
463 options: vec![
464 "ProxyJump=bastion".to_string(),
465 "ConnectTimeout=5".to_string(),
466 ],
467 ..Default::default()
468 },
469 ..Default::default()
470 };
471 assert!(validate_session(&allowed).is_ok());
472
473 for option in [
474 "ProxyCommand=touch /tmp/pwned",
475 "LocalCommand=touch /tmp/pwned",
476 "Unknown=x",
477 ] {
478 let session = SessionConfig {
479 ssh: SshConfig {
480 options: vec![option.to_string()],
481 ..Default::default()
482 },
483 ..Default::default()
484 };
485 assert!(validate_session(&session).is_err(), "accepted {option}");
486 }
487
488 let custom_runtime = SessionConfig {
489 container: ContainerConfig {
490 compose_runtime: Some("touch".to_string()),
491 ..Default::default()
492 },
493 ..Default::default()
494 };
495 assert!(validate_session(&custom_runtime).is_err());
496 assert!(validate_session_with_trust(&custom_runtime, true).is_ok());
497 }
498
499 #[test]
500 fn sql_policy_classifies_transaction_control_without_blocking_normal_sql() {
501 for sql in [
502 "BEGIN",
503 "/* outer /* nested */ comment */ COMMIT",
504 "-- comment\nROLLBACK TO SAVEPOINT s",
505 "START TRANSACTION READ WRITE",
506 "SAVEPOINT s",
507 "RELEASE SAVEPOINT s",
508 "PREPARE TRANSACTION 'x'",
509 "SET TRANSACTION READ WRITE",
510 "SET LOCAL TRANSACTION READ WRITE",
511 "SET SESSION TRANSACTION READ WRITE",
512 "SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE",
513 ] {
514 assert!(validate_sql(sql).is_err(), "accepted {sql}");
515 }
516 for sql in [
517 "select 'commit'",
518 "select begin from keywords",
519 "set statement_timeout = 1000",
520 "notify channel",
521 ] {
522 assert!(validate_sql(sql).is_ok(), "rejected {sql}");
523 }
524 }
525
526 #[test]
527 fn locked_profile_is_selected_by_executable_and_rejects_overrides() {
528 assert_eq!(
529 locked_profile_name("/usr/local/bin/afpsql-readonly").ok(),
530 Some(None)
531 );
532 assert_eq!(
533 locked_profile_name("/usr/local/bin/afpsql-readonly-production").ok(),
534 Some(Some("production".to_string()))
535 );
536 assert!(locked_profile_name("afpsql-readonly-bad$name").is_err());
537 for flag in [
538 "--host",
539 "--ssh",
540 "--container-docker-runtime",
541 "--password",
542 "--dsn",
543 ] {
544 let args = vec![
545 "afpsql-readonly-production".to_string(),
546 flag.to_string(),
547 "value".to_string(),
548 "--sql".to_string(),
549 "select 1".to_string(),
550 ];
551 assert!(
552 validate_raw_args_for_profile(&args, true).is_err(),
553 "accepted {flag}"
554 );
555 }
556 }
557}