1 | #include "duckdb/main/extension_helper.hpp" |
2 | #include "duckdb/common/gzip_file_system.hpp" |
3 | #include "duckdb/common/types/uuid.hpp" |
4 | #include "duckdb/common/string_util.hpp" |
5 | |
6 | #ifndef DISABLE_DUCKDB_REMOTE_INSTALL |
7 | #include "httplib.hpp" |
8 | #endif |
9 | #include "duckdb/common/windows_undefs.hpp" |
10 | |
11 | #include <fstream> |
12 | |
13 | namespace duckdb { |
14 | |
15 | //===--------------------------------------------------------------------===// |
16 | // Install Extension |
17 | //===--------------------------------------------------------------------===// |
18 | const string ExtensionHelper::NormalizeVersionTag(const string &version_tag) { |
19 | if (version_tag.length() > 0 && version_tag[0] != 'v') { |
20 | return "v" + version_tag; |
21 | } |
22 | return version_tag; |
23 | } |
24 | |
25 | bool ExtensionHelper::IsRelease(const string &version_tag) { |
26 | return !StringUtil::Contains(haystack: version_tag, needle: "-dev" ); |
27 | } |
28 | |
29 | const string ExtensionHelper::GetVersionDirectoryName() { |
30 | if (IsRelease(version_tag: DuckDB::LibraryVersion())) { |
31 | return NormalizeVersionTag(version_tag: DuckDB::LibraryVersion()); |
32 | } else { |
33 | return DuckDB::SourceID(); |
34 | } |
35 | } |
36 | |
37 | const vector<string> ExtensionHelper::PathComponents() { |
38 | return vector<string> {".duckdb" , "extensions" , GetVersionDirectoryName(), DuckDB::Platform()}; |
39 | } |
40 | |
41 | string ExtensionHelper::ExtensionDirectory(DBConfig &config, FileSystem &fs) { |
42 | #ifdef WASM_LOADABLE_EXTENSIONS |
43 | static_assertion(0, "ExtensionDirectory functionality is not supported in duckdb-wasm" ); |
44 | #endif |
45 | string extension_directory; |
46 | if (!config.options.extension_directory.empty()) { // create the extension directory if not present |
47 | extension_directory = config.options.extension_directory; |
48 | // TODO this should probably live in the FileSystem |
49 | // convert random separators to platform-canonic |
50 | extension_directory = fs.ConvertSeparators(path: extension_directory); |
51 | // expand ~ in extension directory |
52 | extension_directory = fs.ExpandPath(path: extension_directory); |
53 | if (!fs.DirectoryExists(directory: extension_directory)) { |
54 | auto sep = fs.PathSeparator(); |
55 | auto splits = StringUtil::Split(input: extension_directory, split: sep); |
56 | D_ASSERT(!splits.empty()); |
57 | string extension_directory_prefix; |
58 | if (StringUtil::StartsWith(str: extension_directory, prefix: sep)) { |
59 | extension_directory_prefix = sep; // this is swallowed by Split otherwise |
60 | } |
61 | for (auto &split : splits) { |
62 | extension_directory_prefix = extension_directory_prefix + split + sep; |
63 | if (!fs.DirectoryExists(directory: extension_directory_prefix)) { |
64 | fs.CreateDirectory(directory: extension_directory_prefix); |
65 | } |
66 | } |
67 | } |
68 | } else { // otherwise default to home |
69 | string home_directory = fs.GetHomeDirectory(); |
70 | // exception if the home directory does not exist, don't create whatever we think is home |
71 | if (!fs.DirectoryExists(directory: home_directory)) { |
72 | throw IOException("Can't find the home directory at '%s'\nSpecify a home directory using the SET " |
73 | "home_directory='/path/to/dir' option." , |
74 | home_directory); |
75 | } |
76 | extension_directory = home_directory; |
77 | } |
78 | D_ASSERT(fs.DirectoryExists(extension_directory)); |
79 | |
80 | auto path_components = PathComponents(); |
81 | for (auto &path_ele : path_components) { |
82 | extension_directory = fs.JoinPath(a: extension_directory, path: path_ele); |
83 | if (!fs.DirectoryExists(directory: extension_directory)) { |
84 | fs.CreateDirectory(directory: extension_directory); |
85 | } |
86 | } |
87 | return extension_directory; |
88 | } |
89 | |
90 | string ExtensionHelper::ExtensionDirectory(ClientContext &context) { |
91 | auto &config = DBConfig::GetConfig(context); |
92 | auto &fs = FileSystem::GetFileSystem(context); |
93 | return ExtensionDirectory(config, fs); |
94 | } |
95 | |
96 | bool ExtensionHelper::CreateSuggestions(const string &extension_name, string &message) { |
97 | vector<string> candidates; |
98 | for (idx_t ext_count = ExtensionHelper::DefaultExtensionCount(), i = 0; i < ext_count; i++) { |
99 | candidates.emplace_back(args: ExtensionHelper::GetDefaultExtension(index: i).name); |
100 | } |
101 | for (idx_t ext_count = ExtensionHelper::ExtensionAliasCount(), i = 0; i < ext_count; i++) { |
102 | candidates.emplace_back(args: ExtensionHelper::GetExtensionAlias(index: i).alias); |
103 | } |
104 | auto closest_extensions = StringUtil::TopNLevenshtein(strings: candidates, target: extension_name); |
105 | message = StringUtil::CandidatesMessage(candidates: closest_extensions, candidate: "Candidate extensions" ); |
106 | for (auto &closest : closest_extensions) { |
107 | if (closest == extension_name) { |
108 | message = "Extension \"" + extension_name + "\" is an existing extension.\n" ; |
109 | return true; |
110 | } |
111 | } |
112 | return false; |
113 | } |
114 | |
115 | void ExtensionHelper::InstallExtension(DBConfig &config, FileSystem &fs, const string &extension, bool force_install) { |
116 | #ifdef WASM_LOADABLE_EXTENSIONS |
117 | // Install is currently a no-op |
118 | return; |
119 | #endif |
120 | string local_path = ExtensionDirectory(config, fs); |
121 | InstallExtensionInternal(config, client_config: nullptr, fs, local_path, extension, force_install); |
122 | } |
123 | |
124 | void ExtensionHelper::InstallExtension(ClientContext &context, const string &extension, bool force_install) { |
125 | #ifdef WASM_LOADABLE_EXTENSIONS |
126 | // Install is currently a no-op |
127 | return; |
128 | #endif |
129 | auto &config = DBConfig::GetConfig(context); |
130 | auto &fs = FileSystem::GetFileSystem(context); |
131 | string local_path = ExtensionDirectory(context); |
132 | auto &client_config = ClientConfig::GetConfig(context); |
133 | InstallExtensionInternal(config, client_config: &client_config, fs, local_path, extension, force_install); |
134 | } |
135 | |
136 | unsafe_unique_array<data_t> ReadExtensionFileFromDisk(FileSystem &fs, const string &path, idx_t &file_size) { |
137 | auto source_file = fs.OpenFile(path, flags: FileFlags::FILE_FLAGS_READ); |
138 | file_size = source_file->GetFileSize(); |
139 | auto in_buffer = make_unsafe_uniq_array<data_t>(n: file_size); |
140 | source_file->Read(buffer: in_buffer.get(), nr_bytes: file_size); |
141 | source_file->Close(); |
142 | return in_buffer; |
143 | } |
144 | |
145 | void WriteExtensionFileToDisk(FileSystem &fs, const string &path, void *data, idx_t data_size) { |
146 | auto target_file = fs.OpenFile(path, flags: FileFlags::FILE_FLAGS_WRITE | FileFlags::FILE_FLAGS_APPEND | |
147 | FileFlags::FILE_FLAGS_FILE_CREATE_NEW); |
148 | target_file->Write(buffer: data, nr_bytes: data_size); |
149 | target_file->Close(); |
150 | target_file.reset(); |
151 | } |
152 | |
153 | void ExtensionHelper::InstallExtensionInternal(DBConfig &config, ClientConfig *client_config, FileSystem &fs, |
154 | const string &local_path, const string &extension, bool force_install) { |
155 | if (!config.options.enable_external_access) { |
156 | throw PermissionException("Installing extensions is disabled through configuration" ); |
157 | } |
158 | auto extension_name = ApplyExtensionAlias(extension_name: fs.ExtractBaseName(path: extension)); |
159 | |
160 | string local_extension_path = fs.JoinPath(a: local_path, path: extension_name + ".duckdb_extension" ); |
161 | if (fs.FileExists(filename: local_extension_path) && !force_install) { |
162 | return; |
163 | } |
164 | |
165 | auto uuid = UUID::ToString(input: UUID::GenerateRandomUUID()); |
166 | string temp_path = local_extension_path + ".tmp-" + uuid; |
167 | if (fs.FileExists(filename: temp_path)) { |
168 | fs.RemoveFile(filename: temp_path); |
169 | } |
170 | auto is_http_url = StringUtil::Contains(haystack: extension, needle: "http://" ); |
171 | if (fs.FileExists(filename: extension)) { |
172 | idx_t file_size; |
173 | auto in_buffer = ReadExtensionFileFromDisk(fs, path: extension, file_size); |
174 | WriteExtensionFileToDisk(fs, path: temp_path, data: in_buffer.get(), data_size: file_size); |
175 | |
176 | fs.MoveFile(source: temp_path, target: local_extension_path); |
177 | return; |
178 | } else if (StringUtil::Contains(haystack: extension, needle: "/" ) && !is_http_url) { |
179 | throw IOException("Failed to read extension from \"%s\": no such file" , extension); |
180 | } |
181 | |
182 | #ifdef DISABLE_DUCKDB_REMOTE_INSTALL |
183 | throw BinderException("Remote extension installation is disabled through configuration" ); |
184 | #else |
185 | |
186 | string default_endpoint = "http://extensions.duckdb.org" ; |
187 | string versioned_path = "/${REVISION}/${PLATFORM}/${NAME}.duckdb_extension.gz" ; |
188 | string custom_endpoint = client_config ? client_config->custom_extension_repo : string(); |
189 | string &endpoint = !custom_endpoint.empty() ? custom_endpoint : default_endpoint; |
190 | string url_template = endpoint + versioned_path; |
191 | |
192 | if (is_http_url) { |
193 | url_template = extension; |
194 | extension_name = "" ; |
195 | } |
196 | |
197 | auto url = StringUtil::Replace(source: url_template, from: "${REVISION}" , to: GetVersionDirectoryName()); |
198 | url = StringUtil::Replace(source: url, from: "${PLATFORM}" , to: DuckDB::Platform()); |
199 | url = StringUtil::Replace(source: url, from: "${NAME}" , to: extension_name); |
200 | |
201 | string no_http = StringUtil::Replace(source: url, from: "http://" , to: "" ); |
202 | |
203 | idx_t next = no_http.find(c: '/', pos: 0); |
204 | if (next == string::npos) { |
205 | throw IOException("No slash in URL template" ); |
206 | } |
207 | |
208 | // Push the substring [last, next) on to splits |
209 | auto hostname_without_http = no_http.substr(pos: 0, n: next); |
210 | auto url_local_part = no_http.substr(pos: next); |
211 | |
212 | auto url_base = "http://" + hostname_without_http; |
213 | duckdb_httplib::Client cli(url_base.c_str()); |
214 | |
215 | duckdb_httplib::Headers = {{"User-Agent" , StringUtil::Format(fmt_str: "DuckDB %s %s %s" , params: DuckDB::LibraryVersion(), |
216 | params: DuckDB::SourceID(), params: DuckDB::Platform())}}; |
217 | |
218 | auto res = cli.Get(path: url_local_part.c_str(), headers); |
219 | |
220 | if (!res || res->status != 200) { |
221 | // create suggestions |
222 | string message; |
223 | auto exact_match = ExtensionHelper::CreateSuggestions(extension_name, message); |
224 | if (exact_match) { |
225 | message += "\nAre you using a development build? In this case, extensions might not (yet) be uploaded." ; |
226 | } |
227 | if (res.error() == duckdb_httplib::Error::Success) { |
228 | throw HTTPException(res.value(), "Failed to download extension \"%s\" at URL \"%s%s\"\n%s" , extension_name, |
229 | url_base, url_local_part, message); |
230 | } else { |
231 | throw IOException("Failed to download extension \"%s\" at URL \"%s%s\"\n%s (ERROR %s)" , extension_name, |
232 | url_base, url_local_part, message, to_string(error: res.error())); |
233 | } |
234 | } |
235 | auto decompressed_body = GZipFileSystem::UncompressGZIPString(in: res->body); |
236 | |
237 | WriteExtensionFileToDisk(fs, path: temp_path, data: (void *)decompressed_body.data(), data_size: decompressed_body.size()); |
238 | fs.MoveFile(source: temp_path, target: local_extension_path); |
239 | #endif |
240 | } |
241 | |
242 | } // namespace duckdb |
243 | |