Skip to main content

spg_engine/
largeobject.rs

1//! v7.39 (round 342+, V40) — the two large-object calls that touch a
2//! SERVER FILE.
3//!
4//! `lo_import('/path')` and `lo_export(oid, '/path')` are the only members
5//! of the lo_* family that do file IO, and the engine is `no_std` — it has
6//! no filesystem. The rest of the family (round 306's descriptor table,
7//! `lo_get` / `lo_put` / `lo_from_bytea` / `lo_unlink`) works entirely in
8//! the catalog and needs nothing from the host.
9//!
10//! So these two follow the contract `COPY … FROM '<file>'` already uses
11//! (round 249): the shape and every message live here, in the engine, and
12//! each host — the server and the embedded API — supplies only the
13//! `std::fs` call. That keeps the two hosts saying the same thing.
14//!
15//! PG 18.4, measured:
16//!   * `lo_import` answers the new oid in a column named `lo_import`;
17//!     `lo_export` answers `1` in a column named `lo_export`.
18//!   * a missing input file is
19//!     `could not open server file "/x": No such file or directory`;
20//!   * an unwritable target is
21//!     `could not create server file "/x": No such file or directory`;
22//!   * both are superuser-only: `permission denied for function lo_import`.
23
24use alloc::string::String;
25
26/// A `SELECT lo_import(…)` / `SELECT lo_export(…)` the host must run,
27/// because it reads or writes a file.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum LoFileCall {
30    /// `lo_import('<path>' [, <oid>])`
31    Import { path: String, oid: Option<u32> },
32    /// `lo_export(<oid>, '<path>')`
33    Export { oid: u32, path: String },
34}
35
36impl LoFileCall {
37    /// The result column name PG uses — its own function name.
38    #[must_use]
39    pub const fn column_name(&self) -> &'static str {
40        match self {
41            Self::Import { .. } => "lo_import",
42            Self::Export { .. } => "lo_export",
43        }
44    }
45}
46
47/// Recognise a bare `SELECT lo_import(…)` / `SELECT lo_export(…)`.
48///
49/// Deliberately narrow: only the statement-level spelling is intercepted,
50/// which is the one that reaches a file. Anything else — the call nested
51/// in a larger expression — is left to the ordinary evaluator, which
52/// reports it as unsupported rather than silently doing nothing.
53#[must_use]
54pub fn parse_lo_file_call(sql: &str) -> Option<LoFileCall> {
55    let t = sql.trim().trim_end_matches(';').trim();
56    let rest = strip_prefix_ci(t, "select")?.trim_start();
57    let (name, args) = split_call(rest)?;
58    let args = split_args(args);
59    match name.as_str() {
60        "lo_import" => match args.as_slice() {
61            [p] => Some(LoFileCall::Import {
62                path: string_literal(p)?,
63                oid: None,
64            }),
65            [p, o] => Some(LoFileCall::Import {
66                path: string_literal(p)?,
67                oid: Some(o.trim().parse().ok()?),
68            }),
69            _ => None,
70        },
71        "lo_export" => match args.as_slice() {
72            [o, p] => Some(LoFileCall::Export {
73                oid: o.trim().parse().ok()?,
74                path: string_literal(p)?,
75            }),
76            _ => None,
77        },
78        _ => None,
79    }
80}
81
82/// PG's wording for a file it could not read.
83#[must_use]
84pub fn could_not_open(path: &str, os_error: &str) -> String {
85    alloc::format!(
86        "could not open server file \"{path}\": {}",
87        trim_os(os_error)
88    )
89}
90
91/// PG's wording for a file it could not write.
92#[must_use]
93pub fn could_not_create(path: &str, os_error: &str) -> String {
94    alloc::format!(
95        "could not create server file \"{path}\": {}",
96        trim_os(os_error)
97    )
98}
99
100/// PG's wording when the caller is not a superuser.
101#[must_use]
102pub fn permission_denied(call: &LoFileCall) -> String {
103    alloc::format!("permission denied for function {}", call.column_name())
104}
105
106/// std renders an io::Error as `No such file or directory (os error 2)`;
107/// PG prints only the message.
108fn trim_os(os_error: &str) -> &str {
109    os_error.split(" (os error").next().unwrap_or(os_error)
110}
111
112fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
113    if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
114        Some(&s[prefix.len()..])
115    } else {
116        None
117    }
118}
119
120/// `name ( args )` → (lower-cased name, the text between the parens).
121fn split_call(s: &str) -> Option<(String, &str)> {
122    let open = s.find('(')?;
123    let close = s.rfind(')')?;
124    if close < open {
125        return None;
126    }
127    let name = s[..open].trim().to_ascii_lowercase();
128    if !s[close + 1..].trim().is_empty() {
129        return None;
130    }
131    Some((name, &s[open + 1..close]))
132}
133
134/// Split on commas that are not inside a quoted literal.
135fn split_args(s: &str) -> alloc::vec::Vec<&str> {
136    let mut out = alloc::vec::Vec::new();
137    let mut start = 0usize;
138    let mut in_quote = false;
139    for (i, c) in s.char_indices() {
140        match c {
141            '\'' => in_quote = !in_quote,
142            ',' if !in_quote => {
143                out.push(&s[start..i]);
144                start = i + 1;
145            }
146            _ => {}
147        }
148    }
149    if !s[start..].trim().is_empty() || !out.is_empty() {
150        out.push(&s[start..]);
151    }
152    out
153}
154
155/// `'text'` → `text`, with PG's doubled-quote escape.
156fn string_literal(s: &str) -> Option<String> {
157    let t = s.trim();
158    let inner = t.strip_prefix('\'')?.strip_suffix('\'')?;
159    Some(inner.replace("''", "'"))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use alloc::string::ToString;
166
167    #[test]
168    fn recognises_both_calls() {
169        assert_eq!(
170            parse_lo_file_call("SELECT lo_import('/tmp/a.txt')"),
171            Some(LoFileCall::Import {
172                path: "/tmp/a.txt".to_string(),
173                oid: None
174            })
175        );
176        assert_eq!(
177            parse_lo_file_call("select LO_IMPORT('/tmp/a.txt', 4242);"),
178            Some(LoFileCall::Import {
179                path: "/tmp/a.txt".to_string(),
180                oid: Some(4242)
181            })
182        );
183        assert_eq!(
184            parse_lo_file_call("SELECT lo_export(4242, '/tmp/b.bin')"),
185            Some(LoFileCall::Export {
186                oid: 4242,
187                path: "/tmp/b.bin".to_string()
188            })
189        );
190    }
191
192    #[test]
193    fn leaves_everything_else_alone() {
194        for sql in [
195            "SELECT lo_get(1)",
196            "SELECT 1",
197            "SELECT lo_import('/tmp/a') FROM t",
198            "SELECT length(lo_import('/tmp/a'))",
199            "INSERT INTO t VALUES (lo_import('/tmp/a'))",
200        ] {
201            assert_eq!(parse_lo_file_call(sql), None, "for `{sql}`");
202        }
203    }
204
205    #[test]
206    fn a_path_may_hold_a_comma_or_a_quote() {
207        assert_eq!(
208            parse_lo_file_call("SELECT lo_import('/tmp/a,b.txt')"),
209            Some(LoFileCall::Import {
210                path: "/tmp/a,b.txt".to_string(),
211                oid: None
212            })
213        );
214        assert_eq!(
215            parse_lo_file_call("SELECT lo_import('/tmp/it''s.txt')"),
216            Some(LoFileCall::Import {
217                path: "/tmp/it's.txt".to_string(),
218                oid: None
219            })
220        );
221    }
222}