CurlRepositoryClient.vala 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. namespace Usm {
  2. /**
  3. * A {@link RepositoryClient} that fetches over HTTP/HTTPS by shelling
  4. * out to `curl`.
  5. *
  6. * GIO cannot fetch `http(s)://` URIs on platforms whose GIO build has
  7. * no TLS backend (musl/alpine images report "Operation not supported"),
  8. * so {@link Repository.get_client} routes remote schemes here while
  9. * `file://` stays on {@link GioRepositoryClient}.
  10. *
  11. * Each transfer runs `curl -fsSL --max-time {@link timeout_seconds} --
  12. * <url>` and streams stdout into a `<destination>.part` file, counting
  13. * bytes as they are written: `-s` keeps curl completely silent during
  14. * the transfer and `-S` lets genuine failures print one error line to
  15. * stderr, which is captured and surfaced through the thrown error.
  16. * Progress reports carry the exact byte counts — current from the
  17. * pump's own counter, total from a `Content-Length` probe before the
  18. * transfer — and the finished part file is moved over the destination
  19. * only after curl exits successfully.
  20. */
  21. public class CurlRepositoryClient : RepositoryClient {
  22. /** Default {@link timeout_seconds} when no configuration is supplied. */
  23. public const int DEFAULT_TIMEOUT_SECONDS = 300;
  24. /**
  25. * Seconds a single transfer (and the size probe before it) may run
  26. * before curl aborts it; {@link Repository.get_client} seeds this
  27. * from {@link Configuration.download_timeout}.
  28. */
  29. public int timeout_seconds { get; set; default = DEFAULT_TIMEOUT_SECONDS; }
  30. public CurlRepositoryClient(Repository repo, int timeout = DEFAULT_TIMEOUT_SECONDS) {
  31. repository_config = repo;
  32. timeout_seconds = timeout;
  33. }
  34. public override void download_repository_listing(string path, RepositoryClientProgressCallback? callback = null) throws Error {
  35. download("PACKAGES.usml", path, callback);
  36. }
  37. public override void download_package(string path, RepositoryListingEntry package, RepositoryClientProgressCallback? callback = null) throws Error {
  38. download(package.path, path, callback);
  39. }
  40. private void download(string remote_path, string destination, RepositoryClientProgressCallback? callback) throws Error {
  41. var url = get_for_path(remote_path).to_string();
  42. var filename = Path.get_basename(remote_path);
  43. var part_path = @"$destination.part";
  44. // Exact total up front where the server states one; transfers
  45. // without Content-Length report progress against 0 until the
  46. // final report
  47. var total_bytes = content_length(url);
  48. var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
  49. var argv = new string[] {
  50. "curl", "-fsSL",
  51. "--max-time", timeout_seconds.to_string(),
  52. "--", url
  53. };
  54. Subprocess process;
  55. try {
  56. process = launcher.spawnv(argv);
  57. }
  58. catch(Error e) {
  59. throw new IOError.NOT_FOUND(@"Unable to launch curl to fetch \"$url\" (is curl installed?): $(e.message)");
  60. }
  61. var output = File.new_for_path(part_path).replace(null, false, FileCreateFlags.REPLACE_DESTINATION);
  62. var loop = new MainLoop();
  63. uint64 downloaded = 0;
  64. var failure = "";
  65. var errors = new StringBuilder();
  66. // Both pipes must drain before curl is waited on: stdout closes
  67. // at end-of-body, while an error line can still land on stderr
  68. var pumps_running = 2;
  69. ProgressEmitter report = () => {
  70. if(callback == null) {
  71. return;
  72. }
  73. callback(filename, (int64)downloaded, (int64)total_bytes);
  74. };
  75. ChunkConsumer consume_body = (data, length) => {
  76. var chunk = data[0:length];
  77. size_t written;
  78. try {
  79. output.write_all(chunk, out written);
  80. downloaded += written;
  81. report();
  82. }
  83. catch(Error e) {
  84. failure = @"writing \"$part_path\": $(e.message)";
  85. }
  86. };
  87. Completion finished = () => {
  88. pumps_running--;
  89. if(pumps_running > 0) {
  90. return;
  91. }
  92. process.wait_check_async.begin(null, (source, result) => {
  93. try {
  94. process.wait_check_async.end(result);
  95. }
  96. catch(Error e) {
  97. failure = failure.length > 0 ? failure : e.message;
  98. }
  99. loop.quit();
  100. });
  101. };
  102. pump(process.get_stderr_pipe(), (data, length) => {
  103. // -sS leaves stderr error text only, and only failures talk
  104. if(errors.len < 512) {
  105. errors.append_len((string)data, length);
  106. }
  107. }, finished);
  108. pump(process.get_stdout_pipe(), consume_body, () => {
  109. try {
  110. output.close();
  111. }
  112. catch(Error e) {
  113. failure = @"closing \"$part_path\": $(e.message)";
  114. }
  115. finished();
  116. });
  117. loop.run();
  118. if(failure.length > 0 || errors.len > 0 && !process.get_successful()) {
  119. try {
  120. output.close();
  121. }
  122. catch(Error e) {
  123. }
  124. try {
  125. File.new_for_path(part_path).delete();
  126. }
  127. catch(Error e) {
  128. }
  129. var detail = errors.len > 0 ? ": " + errors.str.strip() : "";
  130. throw new IOError.FAILED(@"curl failed while fetching \"$url\": $(failure.length > 0 ? failure : "transfer error")$detail");
  131. }
  132. // A transfer without Content-Length can only state its total
  133. // once every byte is on disk
  134. if(total_bytes == 0) {
  135. total_bytes = downloaded;
  136. }
  137. report();
  138. File.new_for_path(part_path).move(File.new_for_path(destination), FileCopyFlags.OVERWRITE);
  139. }
  140. private delegate void ProgressEmitter();
  141. private delegate void ChunkConsumer(uint8[] data, ssize_t length);
  142. private delegate void Completion();
  143. /** Reads a pipe to EOF in 8 KiB chunks, handing each chunk to {@link consume} before {@link completed}. */
  144. private async void pump(owned InputStream pipe, owned ChunkConsumer consume, owned Completion completed) {
  145. while(true) {
  146. var buffer = new uint8[8192];
  147. ssize_t read;
  148. try {
  149. read = yield pipe.read_async(buffer);
  150. }
  151. catch(Error e) {
  152. break;
  153. }
  154. if(read <= 0) {
  155. break;
  156. }
  157. consume(buffer, read);
  158. }
  159. completed();
  160. }
  161. /**
  162. * The URL's Content-Length via `curl -I`, or 0 when the server does
  163. * not state one. The probe is silent: headers are consumed from the
  164. * pipe and any stderr is drained and discarded — a failed probe
  165. * merely leaves the total unknown.
  166. */
  167. private uint64 content_length(string url) {
  168. try {
  169. var launcher = new SubprocessLauncher(SubprocessFlags.STDOUT_PIPE | SubprocessFlags.STDERR_PIPE);
  170. var process = launcher.spawnv(new string[] {
  171. "curl", "-fsSI",
  172. "--max-time", timeout_seconds.to_string(),
  173. "--", url
  174. });
  175. var headers = new DataInputStream(process.get_stdout_pipe());
  176. string? found = null;
  177. while(true) {
  178. var header = headers.read_line();
  179. if(header == null) {
  180. break;
  181. }
  182. var separator = header.index_of(":");
  183. if(separator < 1 || header.substring(0, separator).down() != "content-length") {
  184. continue;
  185. }
  186. found = header.substring(separator + 1).strip();
  187. }
  188. process.get_stderr_pipe().read_bytes(1);
  189. if(!process.wait()) {
  190. return 0;
  191. }
  192. return found != null ? uint64.parse(found) : 0;
  193. }
  194. catch(Error e) {
  195. }
  196. return 0;
  197. }
  198. private Uri get_for_path(string path) throws Error {
  199. var uri = Uri.parse(repository_config.url, UriFlags.NONE);
  200. // A scheme-plus-host URL ("http://h:1") has an empty path, and a
  201. // relative joined path would make Uri.build reject the URI
  202. var base_path = uri.get_path() ?? "/";
  203. if(base_path.length == 0) {
  204. base_path = "/";
  205. }
  206. return Uri.build(UriFlags.NONE, uri.get_scheme(), uri.get_userinfo(), uri.get_host(), uri.get_port(), Path.build_filename(base_path, path), uri.get_query(), uri.get_fragment());
  207. }
  208. }
  209. }