files.com 1.1.812 → 1.1.814

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. checksums.yaml +4 -4
  2. data/_VERSION +1 -1
  3. data/doc/Files/APIConnectionError.html +1 -1
  4. data/doc/Files/APIError.html +1 -1
  5. data/doc/Files/AccountAlreadyExistsError.html +1 -1
  6. data/doc/Files/AccountLineItem.html +1 -1
  7. data/doc/Files/AccountOverdueError.html +1 -1
  8. data/doc/Files/Action.html +1 -1
  9. data/doc/Files/ActionLog.html +1 -1
  10. data/doc/Files/ActionNotificationExport.html +1 -1
  11. data/doc/Files/ActionNotificationExportResult.html +1 -1
  12. data/doc/Files/AdditionalAuthenticationRequiredError.html +1 -1
  13. data/doc/Files/AgentNode.html +1 -1
  14. data/doc/Files/AgentNodeConnection.html +1 -1
  15. data/doc/Files/AgentNodeInstance.html +1 -1
  16. data/doc/Files/AgentPushUpdate.html +1 -1
  17. data/doc/Files/AgentPushUpdateBlockedError.html +1 -1
  18. data/doc/Files/AgentUnavailableError.html +1 -1
  19. data/doc/Files/AgentUpgradeRequiredError.html +1 -1
  20. data/doc/Files/AiAssistantPersonality.html +1 -1
  21. data/doc/Files/AiTask.html +1 -1
  22. data/doc/Files/AiTaskCannotBeRunManuallyError.html +1 -1
  23. data/doc/Files/AlreadyCompletedError.html +1 -1
  24. data/doc/Files/Api.html +20 -13
  25. data/doc/Files/ApiClient/RequestLogContext.html +29 -29
  26. data/doc/Files/ApiClient/TransferSettings.html +721 -0
  27. data/doc/Files/ApiClient.html +756 -204
  28. data/doc/Files/ApiKey.html +1 -1
  29. data/doc/Files/ApiKeyIsDisabledError.html +1 -1
  30. data/doc/Files/ApiKeyIsPathRestrictedError.html +1 -1
  31. data/doc/Files/ApiKeyNotFoundError.html +1 -1
  32. data/doc/Files/ApiKeyOnlyForDesktopAppError.html +1 -1
  33. data/doc/Files/ApiKeyOnlyForFileOperationsError.html +1 -1
  34. data/doc/Files/ApiKeyOnlyForMobileAppError.html +1 -1
  35. data/doc/Files/ApiKeyOnlyForOfficeIntegrationError.html +1 -1
  36. data/doc/Files/ApiKeySessionsNotSupportedError.html +1 -1
  37. data/doc/Files/ApiRequestLog.html +1 -1
  38. data/doc/Files/App.html +1 -1
  39. data/doc/Files/As2IncomingMessage.html +1 -1
  40. data/doc/Files/As2OutgoingMessage.html +1 -1
  41. data/doc/Files/As2Partner.html +1 -1
  42. data/doc/Files/As2Station.html +1 -1
  43. data/doc/Files/AttachmentTooLargeError.html +1 -1
  44. data/doc/Files/AuthenticationError.html +1 -1
  45. data/doc/Files/AuthenticationRequiredError.html +1 -1
  46. data/doc/Files/Auto.html +1 -1
  47. data/doc/Files/Automation.html +1 -1
  48. data/doc/Files/AutomationAuthoringSchema.html +1 -1
  49. data/doc/Files/AutomationCannotBeRunManuallyError.html +1 -1
  50. data/doc/Files/AutomationExecutionNode.html +1 -1
  51. data/doc/Files/AutomationLog.html +1 -1
  52. data/doc/Files/AutomationRun.html +1 -1
  53. data/doc/Files/AutomationsUnavailableError.html +1 -1
  54. data/doc/Files/BadRequestError.html +1 -1
  55. data/doc/Files/BandwidthSnapshot.html +1 -1
  56. data/doc/Files/Behavior.html +1 -1
  57. data/doc/Files/BehaviorNotAllowedOnRemoteServerError.html +1 -1
  58. data/doc/Files/BillingInformationHiddenError.html +1 -1
  59. data/doc/Files/BillingPermissionRequiredError.html +1 -1
  60. data/doc/Files/BufferedUploadDisabledForThisDestinationError.html +1 -1
  61. data/doc/Files/Bundle.html +1 -1
  62. data/doc/Files/BundleAction.html +1 -1
  63. data/doc/Files/BundleDownload.html +1 -1
  64. data/doc/Files/BundleMaximumUsesReachedError.html +1 -1
  65. data/doc/Files/BundleNotification.html +1 -1
  66. data/doc/Files/BundleOnlyAllowsPreviewsError.html +1 -1
  67. data/doc/Files/BundleOperationRequiresSubfolderError.html +1 -1
  68. data/doc/Files/BundlePath.html +1 -1
  69. data/doc/Files/BundlePathNotFoundError.html +1 -1
  70. data/doc/Files/BundlePermissionRequiredError.html +1 -1
  71. data/doc/Files/BundleRecipient.html +1 -1
  72. data/doc/Files/BundleRegistration.html +1 -1
  73. data/doc/Files/BundleRegistrationCodeFailedError.html +1 -1
  74. data/doc/Files/BundleRegistrationNotFoundError.html +1 -1
  75. data/doc/Files/CannotAdministerHigherLevelUserError.html +1 -1
  76. data/doc/Files/CannotDownloadDirectoryError.html +1 -1
  77. data/doc/Files/CannotLoginWhileUsingKeyError.html +1 -1
  78. data/doc/Files/CantActForOtherUserError.html +1 -1
  79. data/doc/Files/CantMoveWithMultipleLocationsError.html +1 -1
  80. data/doc/Files/ChatMessage.html +1 -1
  81. data/doc/Files/ChatSession.html +1 -1
  82. data/doc/Files/ChildSiteManagementPolicy.html +1 -1
  83. data/doc/Files/Clickwrap.html +1 -1
  84. data/doc/Files/CodeNotFoundError.html +1 -1
  85. data/doc/Files/ConfigurationLockedPathError.html +1 -1
  86. data/doc/Files/ContactAdminForPasswordChangeHelpError.html +1 -1
  87. data/doc/Files/CouldNotCreateParentError.html +1 -1
  88. data/doc/Files/CustomDomain.html +1 -1
  89. data/doc/Files/DatetimeParseError.html +1 -1
  90. data/doc/Files/DesktopConfigurationProfile.html +1 -1
  91. data/doc/Files/DestinationExistsError.html +1 -1
  92. data/doc/Files/DestinationFolderLimitedError.html +1 -1
  93. data/doc/Files/DestinationParentConflictError.html +1 -1
  94. data/doc/Files/DestinationParentDoesNotExistError.html +1 -1
  95. data/doc/Files/DestinationSameError.html +1 -1
  96. data/doc/Files/DestinationSiteMismatchError.html +1 -1
  97. data/doc/Files/DirectConnectionInfo.html +1 -1
  98. data/doc/Files/DnsRecord.html +1 -1
  99. data/doc/Files/DoesNotSupportSortingError.html +1 -1
  100. data/doc/Files/DuplicateShareRecipientError.html +1 -1
  101. data/doc/Files/EmailIncomingMessage.html +1 -1
  102. data/doc/Files/EmailLog.html +1 -1
  103. data/doc/Files/Error.html +2 -2
  104. data/doc/Files/Errors.html +1 -1
  105. data/doc/Files/EventChannel.html +1 -1
  106. data/doc/Files/EventDeliveryAttempt.html +1 -1
  107. data/doc/Files/EventRecord.html +1 -1
  108. data/doc/Files/EventSubscription.html +1 -1
  109. data/doc/Files/EventTarget.html +1 -1
  110. data/doc/Files/ExavaultApiRequestLog.html +1 -1
  111. data/doc/Files/ExceededRuntimeLimitError.html +1 -1
  112. data/doc/Files/Expectation.html +1 -1
  113. data/doc/Files/ExpectationAlreadyHasOpenWindowError.html +1 -1
  114. data/doc/Files/ExpectationEvaluation.html +1 -1
  115. data/doc/Files/ExpectationIncident.html +1 -1
  116. data/doc/Files/ExpectationNotManualTriggerError.html +1 -1
  117. data/doc/Files/ExpiredPrivateKeyError.html +1 -1
  118. data/doc/Files/ExpiredPublicKeyError.html +1 -1
  119. data/doc/Files/ExportFailureError.html +1 -1
  120. data/doc/Files/ExportNotReadyError.html +1 -1
  121. data/doc/Files/ExternalEvent.html +1 -1
  122. data/doc/Files/FailedToChangePasswordError.html +1 -1
  123. data/doc/Files/File.html +2019 -1515
  124. data/doc/Files/FileAction.html +1 -1
  125. data/doc/Files/FileActionError.html +1 -1
  126. data/doc/Files/FileComment.html +1 -1
  127. data/doc/Files/FileCommentReaction.html +1 -1
  128. data/doc/Files/FileLockedError.html +1 -1
  129. data/doc/Files/FileMigration.html +1 -1
  130. data/doc/Files/FileMigrationLog.html +1 -1
  131. data/doc/Files/FileNotFoundError.html +1 -1
  132. data/doc/Files/FileNotUploadedError.html +1 -1
  133. data/doc/Files/FilePendingProcessingError.html +1 -1
  134. data/doc/Files/FileProcessingErrorError.html +1 -1
  135. data/doc/Files/FileTooBigToDecryptError.html +1 -1
  136. data/doc/Files/FileTooBigToEncryptError.html +1 -1
  137. data/doc/Files/FileUploadNotFoundError.html +1 -1
  138. data/doc/Files/FileUploadPart.html +1 -1
  139. data/doc/Files/FileUploadedToWrongRegionError.html +1 -1
  140. data/doc/Files/FileUtils.html +1 -1
  141. data/doc/Files/FilenameTooLongError.html +1 -1
  142. data/doc/Files/FilesAgentFailedAuthorizationError.html +1 -1
  143. data/doc/Files/FilesNativeStorageOverloadedError.html +1 -1
  144. data/doc/Files/Folder.html +1 -1
  145. data/doc/Files/FolderAdminOrBillingPermissionRequiredError.html +1 -1
  146. data/doc/Files/FolderAdminPermissionRequiredError.html +1 -1
  147. data/doc/Files/FolderLockedError.html +1 -1
  148. data/doc/Files/FolderMustNotBeAFileError.html +1 -1
  149. data/doc/Files/FolderNotEmptyError.html +1 -1
  150. data/doc/Files/FoldersNotAllowedError.html +1 -1
  151. data/doc/Files/FormField.html +1 -1
  152. data/doc/Files/FormFieldSet.html +1 -1
  153. data/doc/Files/FtpActionLog.html +1 -1
  154. data/doc/Files/FullPermissionRequiredError.html +1 -1
  155. data/doc/Files/GpgKey.html +1 -1
  156. data/doc/Files/Group.html +1 -1
  157. data/doc/Files/GroupNotFoundError.html +1 -1
  158. data/doc/Files/GroupUser.html +1 -1
  159. data/doc/Files/History.html +1 -1
  160. data/doc/Files/HistoryExport.html +1 -1
  161. data/doc/Files/HistoryExportResult.html +1 -1
  162. data/doc/Files/HistoryPermissionRequiredError.html +1 -1
  163. data/doc/Files/HistoryUnavailableError.html +1 -1
  164. data/doc/Files/HolidayCalendar.html +1 -1
  165. data/doc/Files/HolidayRegion.html +1 -1
  166. data/doc/Files/Image.html +1 -1
  167. data/doc/Files/InAppAiAssistantUnavailableError.html +1 -1
  168. data/doc/Files/InboundS3Log.html +1 -1
  169. data/doc/Files/InboxNotFoundError.html +1 -1
  170. data/doc/Files/InboxRecipient.html +1 -1
  171. data/doc/Files/InboxRegistration.html +1 -1
  172. data/doc/Files/InboxRegistrationCodeFailedError.html +1 -1
  173. data/doc/Files/InboxUpload.html +1 -1
  174. data/doc/Files/InsufficientPermissionForParamsError.html +1 -1
  175. data/doc/Files/InsufficientPermissionForSiteError.html +1 -1
  176. data/doc/Files/IntegrationCentricProfile.html +1 -1
  177. data/doc/Files/InternalGeneralErrorError.html +1 -1
  178. data/doc/Files/InvalidBodyError.html +1 -1
  179. data/doc/Files/InvalidBundleCodeError.html +1 -1
  180. data/doc/Files/InvalidCredentialsError.html +1 -1
  181. data/doc/Files/InvalidCursorError.html +1 -1
  182. data/doc/Files/InvalidCursorTypeForSortError.html +1 -1
  183. data/doc/Files/InvalidEtagsError.html +1 -1
  184. data/doc/Files/InvalidFileTypeError.html +1 -1
  185. data/doc/Files/InvalidFilenameError.html +1 -1
  186. data/doc/Files/InvalidFilterAliasCombinationError.html +1 -1
  187. data/doc/Files/InvalidFilterFieldError.html +1 -1
  188. data/doc/Files/InvalidFilterParamError.html +1 -1
  189. data/doc/Files/InvalidFilterParamFormatError.html +1 -1
  190. data/doc/Files/InvalidFilterParamValueError.html +1 -1
  191. data/doc/Files/InvalidInputEncodingError.html +1 -1
  192. data/doc/Files/InvalidInterfaceError.html +1 -1
  193. data/doc/Files/InvalidOauthError.html +1 -1
  194. data/doc/Files/InvalidOauthProviderError.html +1 -1
  195. data/doc/Files/InvalidOrExpiredCodeError.html +1 -1
  196. data/doc/Files/InvalidParameterError.html +5 -1
  197. data/doc/Files/InvalidPathError.html +1 -1
  198. data/doc/Files/InvalidPriorityColorError.html +1 -1
  199. data/doc/Files/InvalidRangeError.html +1 -1
  200. data/doc/Files/InvalidReturnToUrlError.html +1 -1
  201. data/doc/Files/InvalidSearchQueryError.html +1 -1
  202. data/doc/Files/InvalidSessionError.html +1 -1
  203. data/doc/Files/InvalidSiteError.html +1 -1
  204. data/doc/Files/InvalidSortFieldError.html +1 -1
  205. data/doc/Files/InvalidSortFilterCombinationError.html +1 -1
  206. data/doc/Files/InvalidUploadOffsetError.html +1 -1
  207. data/doc/Files/InvalidUploadPartGapError.html +1 -1
  208. data/doc/Files/InvalidUploadPartSizeError.html +1 -1
  209. data/doc/Files/InvalidUsernameOrPasswordError.html +1 -1
  210. data/doc/Files/InvalidWorkspaceIdHeaderError.html +1 -1
  211. data/doc/Files/InvalidZipFileError.html +1 -1
  212. data/doc/Files/Invoice.html +1 -1
  213. data/doc/Files/InvoiceLineItem.html +1 -1
  214. data/doc/Files/IpAddress.html +1 -1
  215. data/doc/Files/KeyLifecycleRule.html +1 -1
  216. data/doc/Files/List.html +1 -1
  217. data/doc/Files/Lock.html +1 -1
  218. data/doc/Files/LockOperationBusyError.html +1 -1
  219. data/doc/Files/LockedOutError.html +1 -1
  220. data/doc/Files/LockoutRegionMismatchError.html +1 -1
  221. data/doc/Files/MetadataCategory.html +1 -1
  222. data/doc/Files/MetadataNotSupportedOnRemotesError.html +1 -1
  223. data/doc/Files/MethodNotAllowedError.html +1 -1
  224. data/doc/Files/MigrationInProgressError.html +1 -1
  225. data/doc/Files/MissingParameterError.html +1 -1
  226. data/doc/Files/ModelSaveErrorError.html +1 -1
  227. data/doc/Files/MoverAccessDeniedError.html +1 -1
  228. data/doc/Files/MoverPackageRequiredError.html +1 -1
  229. data/doc/Files/MultipleProcessingErrorsError.html +1 -1
  230. data/doc/Files/MultipleSortParamsNotAllowedError.html +1 -1
  231. data/doc/Files/MustAuthenticateWithApiKeyError.html +1 -1
  232. data/doc/Files/NeedAdminPermissionForInboxError.html +1 -1
  233. data/doc/Files/NestedNotFoundError.html +1 -1
  234. data/doc/Files/NoAccountForSiteError.html +1 -1
  235. data/doc/Files/NoValidInputParamsError.html +1 -1
  236. data/doc/Files/NonAdminsMustQueryByFolderOrPathError.html +1 -1
  237. data/doc/Files/NotAllowedToCreateBundleError.html +1 -1
  238. data/doc/Files/NotAuthenticatedError.html +1 -1
  239. data/doc/Files/NotAuthorizedError.html +1 -1
  240. data/doc/Files/NotEnqueuableSyncError.html +1 -1
  241. data/doc/Files/NotFoundError.html +1 -1
  242. data/doc/Files/NotImplementedError.html +1 -1
  243. data/doc/Files/Notification.html +1 -1
  244. data/doc/Files/OffsetUploadNotAllowedWithMalwareScanningError.html +1 -1
  245. data/doc/Files/OneTimePasswordIncorrectError.html +1 -1
  246. data/doc/Files/OutboundConnectionLog.html +1 -1
  247. data/doc/Files/PartNumberTooLargeError.html +1 -1
  248. data/doc/Files/Partner.html +1 -1
  249. data/doc/Files/PartnerChannel.html +1 -1
  250. data/doc/Files/PartnerChannelTemplate.html +1 -1
  251. data/doc/Files/PartnerConnection.html +1 -1
  252. data/doc/Files/PartnerSite.html +1 -1
  253. data/doc/Files/PartnerSiteRequest.html +1 -1
  254. data/doc/Files/PasswordChangeNotRequiredError.html +1 -1
  255. data/doc/Files/PasswordChangeRequiredError.html +1 -1
  256. data/doc/Files/PathCannotHaveTrailingWhitespaceError.html +1 -1
  257. data/doc/Files/PathTooLongError.html +1 -1
  258. data/doc/Files/PathUtil.html +1 -1
  259. data/doc/Files/Payment.html +1 -1
  260. data/doc/Files/PaymentLineItem.html +1 -1
  261. data/doc/Files/PaymentMethodErrorError.html +1 -1
  262. data/doc/Files/PendingWorkEvent.html +1 -1
  263. data/doc/Files/Permission.html +1 -1
  264. data/doc/Files/PlanNotFoundError.html +1 -1
  265. data/doc/Files/Preview.html +1 -1
  266. data/doc/Files/PreviewOnlyPermissionCannotDownloadError.html +1 -1
  267. data/doc/Files/ProcessingFailureError.html +1 -1
  268. data/doc/Files/PublicHostingRequestLog.html +1 -1
  269. data/doc/Files/PublicIpAddress.html +1 -1
  270. data/doc/Files/PublicKey.html +1 -1
  271. data/doc/Files/RateLimitedError.html +1 -1
  272. data/doc/Files/ReadOnlySessionError.html +1 -1
  273. data/doc/Files/ReadPermissionRequiredError.html +1 -1
  274. data/doc/Files/ReauthenticationFailedError.html +1 -1
  275. data/doc/Files/ReauthenticationFailedFinalError.html +1 -1
  276. data/doc/Files/ReauthenticationNeededActionError.html +1 -1
  277. data/doc/Files/ReauthenticationNeededFieldsError.html +1 -1
  278. data/doc/Files/ReauthenticationRateLimitedError.html +1 -1
  279. data/doc/Files/RecaptchaFailedError.html +1 -1
  280. data/doc/Files/RecipientAlreadySharedError.html +1 -1
  281. data/doc/Files/RemoteBandwidthSnapshot.html +1 -1
  282. data/doc/Files/RemoteDesktopDebugLoggingDisabledError.html +1 -1
  283. data/doc/Files/RemoteEntryReadOnlyError.html +1 -1
  284. data/doc/Files/RemoteMountBackend.html +1 -1
  285. data/doc/Files/RemoteServer.html +1 -1
  286. data/doc/Files/RemoteServerConfigurationFile.html +1 -1
  287. data/doc/Files/RemoteServerCredential.html +1 -1
  288. data/doc/Files/RemoteServerErrorError.html +1 -1
  289. data/doc/Files/Request.html +1 -1
  290. data/doc/Files/RequestBodyTooLargeError.html +1 -1
  291. data/doc/Files/RequestParamsContainInvalidCharacterError.html +1 -1
  292. data/doc/Files/RequestParamsInvalidError.html +1 -1
  293. data/doc/Files/RequestParamsRequiredError.html +1 -1
  294. data/doc/Files/ResourceBelongsToParentSiteError.html +1 -1
  295. data/doc/Files/ResourceLockedError.html +1 -1
  296. data/doc/Files/Response.html +1 -1
  297. data/doc/Files/Restore.html +1 -1
  298. data/doc/Files/RootFolderBehaviorSiteAdminRequiredError.html +1 -1
  299. data/doc/Files/RootFolderBehaviorSkipSiteAdminRequiredError.html +1 -1
  300. data/doc/Files/Schedule.html +1 -1
  301. data/doc/Files/ScheduledExport.html +1 -1
  302. data/doc/Files/ScimLog.html +1 -1
  303. data/doc/Files/SearchAllOnChildPathError.html +1 -1
  304. data/doc/Files/SearchUnavailableError.html +1 -1
  305. data/doc/Files/Secret.html +1 -1
  306. data/doc/Files/SelfManagedRequiredError.html +1 -1
  307. data/doc/Files/ServiceUnavailableError.html +1 -1
  308. data/doc/Files/Session.html +1 -1
  309. data/doc/Files/SettingsChange.html +1 -1
  310. data/doc/Files/SftpActionLog.html +1 -1
  311. data/doc/Files/SftpHostKey.html +1 -1
  312. data/doc/Files/ShareGroup.html +1 -1
  313. data/doc/Files/ShareGroupMember.html +1 -1
  314. data/doc/Files/SiemHttpDestination.html +1 -1
  315. data/doc/Files/SiemHttpDestinationEvent.html +1 -1
  316. data/doc/Files/Site.html +1 -1
  317. data/doc/Files/SiteAdminOrPartnerAdminPermissionRequiredError.html +1 -1
  318. data/doc/Files/SiteAdminOrWorkspaceAdminOrFolderAdminPermissionRequiredError.html +1 -1
  319. data/doc/Files/SiteAdminOrWorkspaceAdminOrPartnerAdminOrFolderAdminPermissionRequiredError.html +1 -1
  320. data/doc/Files/SiteAdminOrWorkspaceAdminOrPartnerAdminPermissionRequiredError.html +1 -1
  321. data/doc/Files/SiteAdminOrWorkspaceAdminPermissionRequiredError.html +1 -1
  322. data/doc/Files/SiteAdminRequiredError.html +1 -1
  323. data/doc/Files/SiteConfigurationError.html +1 -1
  324. data/doc/Files/SiteDisabledError.html +1 -1
  325. data/doc/Files/SiteFilesAreImmutableError.html +1 -1
  326. data/doc/Files/SiteNotFoundError.html +1 -1
  327. data/doc/Files/SiteSubdomainRedirect.html +1 -1
  328. data/doc/Files/SiteWasRemovedError.html +1 -1
  329. data/doc/Files/SizableIO.html +43 -41
  330. data/doc/Files/Snapshot.html +1 -1
  331. data/doc/Files/SsoEvent.html +1 -1
  332. data/doc/Files/SsoStrategy.html +1 -1
  333. data/doc/Files/Status.html +1 -1
  334. data/doc/Files/Style.html +1 -1
  335. data/doc/Files/SubfolderLockedError.html +1 -1
  336. data/doc/Files/Sync.html +1 -1
  337. data/doc/Files/SyncInProgressError.html +1 -1
  338. data/doc/Files/SyncLog.html +1 -1
  339. data/doc/Files/SyncRun.html +1 -1
  340. data/doc/Files/SyncRunLiveTransfer.html +1 -1
  341. data/doc/Files/SystemProfiler.html +1 -1
  342. data/doc/Files/TooManyConcurrentLoginsError.html +1 -1
  343. data/doc/Files/TooManyConcurrentRequestsError.html +1 -1
  344. data/doc/Files/TooManyLoginAttemptsError.html +1 -1
  345. data/doc/Files/TooManyRequestsError.html +1 -1
  346. data/doc/Files/TooManySharesError.html +1 -1
  347. data/doc/Files/Transfer/BufferLimitError.html +175 -0
  348. data/doc/Files/Transfer/ByteBudget/Reservation.html +301 -0
  349. data/doc/Files/Transfer/ByteBudget.html +748 -0
  350. data/doc/Files/Transfer/CancelledError.html +235 -0
  351. data/doc/Files/Transfer/Checkpoint.html +1371 -0
  352. data/doc/Files/Transfer/CheckpointError.html +173 -0
  353. data/doc/Files/Transfer/ClientTransport/ClientResponse.html +661 -0
  354. data/doc/Files/Transfer/ClientTransport/Reader.html +530 -0
  355. data/doc/Files/Transfer/ClientTransport.html +675 -0
  356. data/doc/Files/Transfer/Clock.html +189 -0
  357. data/doc/Files/Transfer/Control.html +667 -0
  358. data/doc/Files/Transfer/Controller/Attempt.html +465 -0
  359. data/doc/Files/Transfer/Controller.html +1166 -0
  360. data/doc/Files/Transfer/Coordinator/Timing.html +568 -0
  361. data/doc/Files/Transfer/Coordinator.html +534 -0
  362. data/doc/Files/Transfer/DownloadCoordinator/FileSink.html +976 -0
  363. data/doc/Files/Transfer/DownloadCoordinator/IoSink.html +1168 -0
  364. data/doc/Files/Transfer/DownloadCoordinator/Piece.html +875 -0
  365. data/doc/Files/Transfer/DownloadCoordinator.html +728 -0
  366. data/doc/Files/Transfer/FileSlots.html +480 -0
  367. data/doc/Files/Transfer/FrontendResponseError.html +235 -0
  368. data/doc/Files/Transfer/Holds.html +505 -0
  369. data/doc/Files/Transfer/HttpTransport.html +579 -0
  370. data/doc/Files/Transfer/Job.html +2338 -0
  371. data/doc/Files/Transfer/Options.html +924 -0
  372. data/doc/Files/Transfer/PausedError.html +319 -0
  373. data/doc/Files/Transfer/Policy.html +1642 -0
  374. data/doc/Files/Transfer/RequestPermits.html +160 -0
  375. data/doc/Files/Transfer/SourceChangedError.html +235 -0
  376. data/doc/Files/Transfer/StorageResponse.html +546 -0
  377. data/doc/Files/Transfer/StorageRetry.html +1098 -0
  378. data/doc/Files/Transfer/UploadCoordinator/Grant.html +1048 -0
  379. data/doc/Files/Transfer/UploadCoordinator/Part.html +1048 -0
  380. data/doc/Files/Transfer/UploadCoordinator/Region.html +295 -0
  381. data/doc/Files/Transfer/UploadCoordinator/Segments.html +299 -0
  382. data/doc/Files/Transfer/UploadCoordinator/Source.html +725 -0
  383. data/doc/Files/Transfer/UploadCoordinator/StreamSource.html +664 -0
  384. data/doc/Files/Transfer/UploadCoordinator.html +546 -0
  385. data/doc/Files/Transfer/UploadPlan.html +522 -0
  386. data/doc/Files/Transfer/VersionChangedError.html +235 -0
  387. data/doc/Files/Transfer.html +134 -0
  388. data/doc/Files/TrialExpiredError.html +1 -1
  389. data/doc/Files/TrialLockedError.html +1 -1
  390. data/doc/Files/TwoFactorAuthenticationCodeAlreadySentError.html +1 -1
  391. data/doc/Files/TwoFactorAuthenticationCountryBlacklistedError.html +1 -1
  392. data/doc/Files/TwoFactorAuthenticationErrorError.html +1 -1
  393. data/doc/Files/TwoFactorAuthenticationGeneralErrorError.html +1 -1
  394. data/doc/Files/TwoFactorAuthenticationMethodUnsupportedErrorError.html +1 -1
  395. data/doc/Files/TwoFactorAuthenticationRequiredError.html +1 -1
  396. data/doc/Files/TwoFactorAuthenticationSetupExpiredError.html +1 -1
  397. data/doc/Files/TwoFactorAuthenticationUnsubscribedRecipientError.html +1 -1
  398. data/doc/Files/URI.html +1 -1
  399. data/doc/Files/UnrecognizedSortIndexError.html +1 -1
  400. data/doc/Files/UnsupportedCurrencyError.html +1 -1
  401. data/doc/Files/UnsupportedHttpResponseFormatError.html +1 -1
  402. data/doc/Files/UnsupportedMediaTypeError.html +1 -1
  403. data/doc/Files/UpdatesNotAllowedForRemotesError.html +1 -1
  404. data/doc/Files/UploadsUnavailableError.html +1 -1
  405. data/doc/Files/UsageByTopLevelDir.html +1 -1
  406. data/doc/Files/UsageDailySnapshot.html +1 -1
  407. data/doc/Files/UsageSnapshot.html +1 -1
  408. data/doc/Files/User.html +1 -1
  409. data/doc/Files/UserAdditionalEmailRecipient.html +1 -1
  410. data/doc/Files/UserCipherUse.html +1 -1
  411. data/doc/Files/UserIdInvalidError.html +1 -1
  412. data/doc/Files/UserIdOnUserEndpointError.html +1 -1
  413. data/doc/Files/UserIdWithoutSiteAdminError.html +1 -1
  414. data/doc/Files/UserLifecycleRule.html +1 -1
  415. data/doc/Files/UserNotFoundError.html +1 -1
  416. data/doc/Files/UserRequest.html +1 -1
  417. data/doc/Files/UserRequestsEnabledRequiredError.html +1 -1
  418. data/doc/Files/UserRequiredError.html +1 -1
  419. data/doc/Files/UserSecurityEvent.html +1 -1
  420. data/doc/Files/UserSftpClientUse.html +1 -1
  421. data/doc/Files/Util/LogSettings.html +1 -1
  422. data/doc/Files/Util.html +1 -1
  423. data/doc/Files/WebDavActionLog.html +1 -1
  424. data/doc/Files/WebhookTest.html +1 -1
  425. data/doc/Files/Workspace.html +1 -1
  426. data/doc/Files/WriteAndBundlePermissionRequiredError.html +1 -1
  427. data/doc/Files/WritePermissionRequiredError.html +1 -1
  428. data/doc/Files/ZipListEntry.html +1 -1
  429. data/doc/Files.html +116 -116
  430. data/doc/_index.html +314 -4
  431. data/doc/class_list.html +1 -1
  432. data/doc/file.README.html +1 -1
  433. data/doc/index.html +1 -1
  434. data/doc/method_list.html +4703 -2415
  435. data/doc/top-level-namespace.html +1 -1
  436. data/docs/file.md +346 -0
  437. data/lib/files.com/api.rb +6 -1
  438. data/lib/files.com/api_client.rb +220 -32
  439. data/lib/files.com/models/file.rb +124 -17
  440. data/lib/files.com/sizable_io.rb +1 -0
  441. data/lib/files.com/transfer/budgets.rb +123 -0
  442. data/lib/files.com/transfer/checkpoint.rb +113 -0
  443. data/lib/files.com/transfer/clock.rb +14 -0
  444. data/lib/files.com/transfer/controller.rb +549 -0
  445. data/lib/files.com/transfer/download_coordinator.rb +906 -0
  446. data/lib/files.com/transfer/errors.rb +100 -0
  447. data/lib/files.com/transfer/http_transport.rb +337 -0
  448. data/lib/files.com/transfer/job.rb +778 -0
  449. data/lib/files.com/transfer/options.rb +126 -0
  450. data/lib/files.com/transfer/policy.rb +91 -0
  451. data/lib/files.com/transfer/storage_retry.rb +169 -0
  452. data/lib/files.com/transfer/upload_coordinator.rb +657 -0
  453. data/lib/files.com/transfer/upload_plan.rb +157 -0
  454. data/lib/files.com.rb +13 -0
  455. data/spec/lib/api_client_spec.rb +125 -3
  456. data/spec/models/file_transfer_spec.rb +70 -4
  457. data/spec/support/transfer_server.rb +113 -14
  458. data/spec/transfer/adaptive_download_spec.rb +1102 -0
  459. data/spec/transfer/adaptive_upload_spec.rb +1602 -0
  460. data/spec/transfer/concurrency_controller_spec.rb +382 -0
  461. data/spec/workspace_id_spec.rb +19 -14
  462. metadata +59 -1
@@ -0,0 +1,1102 @@
1
+ require "spec_helper"
2
+ require "logger"
3
+ require "stringio"
4
+ require "tempfile"
5
+ require "tmpdir"
6
+ require_relative "../support/transfer_server"
7
+
8
+ # Adaptive downloads from a real loopback server playing storage, and the Files.com API where a download needs a
9
+ # fresh URL. The job's own connections make every request below; only the server's answers are scripted.
10
+ RSpec.describe Files::File, "adaptive downloads" do
11
+ # Ten full 64 KiB ranges and a short one, with every byte value.
12
+ let(:payload) { Random.new(7).bytes((10 * 65_536) + 1000) }
13
+ # A quarter of it is the 64 KiB range size.
14
+ let(:transfer) { { buffer_bytes: 262_144 } }
15
+ let(:sink) { StringIO.new("".b) }
16
+ let(:servers) { [] }
17
+
18
+ around do |example|
19
+ original = [ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id ]
20
+ Files.api_key = "fixture-api-key"
21
+ Files.max_network_retries = 0
22
+ Files.session_id = "fixture-session"
23
+ example.run
24
+ ensure
25
+ Files.api_key, Files.base_url, Files.max_network_retries, Files.session_id = original
26
+ end
27
+
28
+ after { servers.each(&:close) }
29
+
30
+ # Storage for report.bin: a Range is answered 206 with its exact Content-Range and etag, a request without one 200
31
+ # with all of content. With hold, each range after the opening one waits until that many requests have been in
32
+ # flight at once (at most five seconds). answer, when given, receives each request and that default response and
33
+ # returns what to send.
34
+ def storage_server(content = payload, etag: "\"v1\"", hold: nil, answer: nil)
35
+ store = TransferServer.new(concurrent: true) do |request|
36
+ range = request.headers["range"]
37
+ store.wait_for_peak(hold) if hold && range && !range.start_with?("bytes=0-")
38
+ default = stored(content, range, etag)
39
+ answer ? answer.call(request, default) : default
40
+ end
41
+ servers << store
42
+ store
43
+ end
44
+
45
+ def stored(content, range, etag)
46
+ return TransferServer.response("200 OK", { "ETag" => etag, "Content-Length" => content.bytesize }, content) unless range
47
+
48
+ first, last = range.delete_prefix("bytes=").split("-").map(&:to_i)
49
+ last = [ last, content.bytesize - 1 ].min
50
+ body = content.byteslice(first..last)
51
+ TransferServer.response("206 Partial Content", { "ETag" => etag, "Content-Range" => "bytes #{first}-#{last}/#{content.bytesize}", "Content-Length" => body.bytesize }, body)
52
+ end
53
+
54
+ # The API, made Files.base_url, answering each file lookup with a download URL on store.
55
+ def api_server(store)
56
+ api = TransferServer.new(concurrent: true) do |_request|
57
+ body = JSON.generate(path: "report.bin", type: "file", download_uri: store.url("/objects/report.bin"))
58
+ TransferServer.response("200 OK", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
59
+ end
60
+ servers << api
61
+ Files.base_url = api.url("")
62
+ api
63
+ end
64
+
65
+ def remote_file(store)
66
+ described_class.new(path: "report.bin", download_uri: store.url("/objects/report.bin"))
67
+ end
68
+
69
+ def ranges(store)
70
+ store.requests.map { |request| request.headers["range"] }
71
+ end
72
+
73
+ # Waits until the block is true, for at most five seconds.
74
+ def wait_until
75
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
76
+ sleep 0.01 until yield || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
77
+ end
78
+
79
+ # A download_client that sends through a connection of its own: a logger of its own makes it a caller's client.
80
+ def own_client
81
+ Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(StringIO.new))
82
+ end
83
+
84
+ describe "a bound version" do
85
+ it "fetches ranges at once with If-Match, within the buffer, and writes them in order" do
86
+ store = storage_server(hold: 4)
87
+
88
+ Files::Transfer::Job.open(**transfer) do |job|
89
+ remote_file(store).download_content(sink, transfer: job)
90
+ expect(job.held_bytes).to eq(0)
91
+ end
92
+
93
+ expect(sink.string).to eq(payload)
94
+ expect(store.peak_in_flight).to eq(4)
95
+ opening, *rest = store.requests
96
+ expect(opening.headers.values_at("range", "accept-encoding", "if-match")).to eq([ "bytes=0-65535", "identity", nil ])
97
+ expect(rest.map { |request| request.headers.values_at("accept-encoding", "if-match") }.uniq).to eq([ [ "identity", "\"v1\"" ] ])
98
+ expect(rest.map { |request| request.headers["range"] }.sort_by { |range| range[/\d+/].to_i })
99
+ .to eq((1..10).map { |index| "bytes=#{index * 65_536}-#{[ ((index + 1) * 65_536) - 1, payload.bytesize - 1 ].min}" })
100
+ expect(sink).not_to be_closed
101
+ end
102
+
103
+ it "stops with VersionChangedError when storage no longer has the version, keeping what was written and closing the IO" do
104
+ store = storage_server(answer: ->(request, default) { request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default })
105
+
106
+ expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
107
+ expect(sink.string).to eq(payload.byteslice(0, 65_536))
108
+ expect(sink).to be_closed
109
+ end
110
+
111
+ it "refuses a range answered for other bytes, without retrying it" do
112
+ Files.max_network_retries = 2
113
+ wrong = TransferServer.response("206 Partial Content", { "ETag" => "\"v1\"", "Content-Range" => "bytes 0-9/#{payload.bytesize}", "Content-Length" => 10 }, payload.byteslice(0, 10))
114
+ store = storage_server(answer: ->(request, default) { request.headers["if-match"] ? wrong : default })
115
+
116
+ expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::APIError, "Transfer response did not match the request")
117
+ expect(ranges(store).tally.values.uniq).to eq([ 1 ])
118
+ end
119
+
120
+ it "retries a range storage throttled and writes each byte once" do
121
+ Files.max_network_retries = 1
122
+ lock = Mutex.new
123
+ throttled = false
124
+ store = storage_server(answer: lambda { |request, default|
125
+ first = lock.synchronize { request.headers["range"] == "bytes=65536-131071" && !throttled && (throttled = true) }
126
+ first ? TransferServer.response("503 Slow Down", { "Retry-After" => "0", "Content-Length" => 0 }) : default
127
+ }
128
+ )
129
+
130
+ remote_file(store).download_content(sink, transfer: transfer)
131
+
132
+ expect(sink.string).to eq(payload)
133
+ expect(ranges(store).count("bytes=65536-131071")).to eq(2)
134
+ end
135
+
136
+ it "reports progress on the caller's thread after each write, and what the job accepted" do
137
+ store = storage_server
138
+ reports = []
139
+ threads = []
140
+
141
+ Files::Transfer::Job.open(**transfer) do |job|
142
+ remote_file(store).download_content(sink, progress: ->(report) { (reports << report) && (threads << Thread.current) }, transfer: job)
143
+
144
+ diagnostics = job.diagnostics
145
+ expect(diagnostics.values_at(:accepted_bytes, :requests, :retries)).to eq([ payload.bytesize, 0, 0 ])
146
+ expect(diagnostics[:elapsed_seconds]).to be > 0
147
+ expect(diagnostics[:goodput_bytes_per_second]).to be > 0
148
+ end
149
+
150
+ expect(sink.string).to eq(payload)
151
+ expect(threads.uniq).to eq([ Thread.current ])
152
+ written = reports.map { |report| report[:written_bytes] }
153
+ expect(written).to eq(written.sort)
154
+ expect(written.last).to eq(payload.bytesize)
155
+ expect(reports.map { |report| report[:total_bytes] }.uniq).to eq([ payload.bytesize ])
156
+ expect(reports.map { |report| report[:bytes_per_second] }).to all(be >= 0)
157
+ end
158
+
159
+ it "refuses progress without transfer" do
160
+ expect { remote_file(storage_server).download_content(sink, progress: ->(_report) {}) }.to raise_error(Files::InvalidParameterError)
161
+ end
162
+
163
+ it "refuses a download_client whose adapter cannot stream before requesting or writing anything, and downloads through it without transfer:" do
164
+ requests = []
165
+ stubs = Faraday::Adapter::Test::Stubs.new
166
+ stubs.get("/objects/report.bin") do |env|
167
+ requests << env.request_headers["Range"]
168
+ [ 200, { "ETag" => "\"v1\"", "Content-Length" => payload.bytesize.to_s }, payload ]
169
+ end
170
+ Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Faraday.new { |builder| builder.adapter :test, stubs })
171
+ remote = described_class.new(path: "report.bin", download_uri: "https://storage.example.test/objects/report.bin")
172
+
173
+ begin
174
+ Dir.mktmpdir do |dir|
175
+ local_path = File.join(dir, "report.bin")
176
+ File.binwrite(local_path, "kept")
177
+ expect { remote.download_file(local_path, transfer: transfer) }.to raise_error(Files::Transfer::BufferLimitError, /cannot stream/)
178
+ expect(File.binread(local_path)).to eq("kept")
179
+ end
180
+ expect { remote.download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::BufferLimitError)
181
+ expect(requests).to be_empty
182
+ expect(sink.string).to be_empty
183
+ expect(sink).not_to be_closed
184
+
185
+ remote.download_content(sink)
186
+ ensure
187
+ Thread.current[:files_api_client_download_client] = nil
188
+ end
189
+
190
+ expect(sink.string).to eq(payload)
191
+ expect(requests).to eq([ nil ])
192
+ end
193
+
194
+ it "streams every request through a download_client with its own logger, over its adapter, one at a time" do
195
+ store = storage_server
196
+ log = StringIO.new
197
+ Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(log))
198
+
199
+ begin
200
+ Files::Transfer::Job.open(**transfer) do |job|
201
+ remote_file(store).download_content(sink, transfer: job)
202
+
203
+ expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
204
+ expect(job.held_bytes).to eq(0)
205
+ end
206
+ ensure
207
+ Thread.current[:files_api_client_download_client] = nil
208
+ end
209
+
210
+ expect(sink.string).to eq(payload)
211
+ expect(store.peak_in_flight).to eq(1)
212
+ expect(ranges(store).size).to eq(11)
213
+ expect(log.string).to include("[transfer]")
214
+ end
215
+
216
+ it "streams a whole response larger than buffer_bytes through such a client, and refuses the web app's before writing it" do
217
+ Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true), logger: Logger.new(StringIO.new))
218
+
219
+ begin
220
+ whole = storage_server(etag: "W/\"v1\"")
221
+ remote_file(whole).download_content(sink, transfer: transfer)
222
+ expect(sink.string).to eq(payload)
223
+ expect(ranges(whole)).to eq([ "bytes=0-65535", nil ])
224
+
225
+ page = "<html>Sign in</html>"
226
+ frontend = storage_server(answer: lambda { |_request, _default|
227
+ TransferServer.response("206 Partial Content", { "X-Files-Frontend-App" => "true", "ETag" => "\"v1\"", "Content-Range" => "bytes 0-19/20", "Content-Length" => 20 }, page)
228
+ }
229
+ )
230
+ refused = StringIO.new("".b)
231
+ expect { remote_file(frontend).download_content(refused, transfer: transfer) }.to raise_error(Files::Transfer::FrontendResponseError)
232
+ expect(refused.string).to be_empty
233
+ expect(refused).to be_closed
234
+ ensure
235
+ Thread.current[:files_api_client_download_client] = nil
236
+ end
237
+ end
238
+
239
+ it "streams through a download_client whose adapter has a configuration block of its own, calling it for each connection" do
240
+ store = storage_server
241
+ configured = []
242
+ conn = Files::ApiClient.build_default_conn(force_net_http: true)
243
+ conn.adapter(:net_http) { |http| configured << http.port }
244
+ Thread.current[:files_api_client_download_client] = Files::ApiClient.new(conn)
245
+
246
+ begin
247
+ Files::Transfer::Job.open(**transfer) do |job|
248
+ remote_file(store).download_content(sink, transfer: job)
249
+
250
+ expect(job.diagnostics[:notes]).to include(a_string_including("one request at a time"))
251
+ end
252
+ ensure
253
+ Thread.current[:files_api_client_download_client] = nil
254
+ end
255
+
256
+ expect(sink.string).to eq(payload)
257
+ expect(configured.uniq).to eq([ URI(store.url("")).port ])
258
+ expect(configured.size).to eq(store.requests.size)
259
+ end
260
+
261
+ it "keeps a download_client made after Files.proxy changed on its own proxy in a job made before, and uses the job's own connections when their proxies match" do
262
+ store = storage_server
263
+ # A proxy that answers each request itself, as storage does, and records the whole URL it was asked for.
264
+ proxy = storage_server
265
+ original = Files.proxy
266
+ later = StringIO.new("".b)
267
+ notes = []
268
+ # The thread keeps its default client for good, so it is made, as the downloads' API context will find it,
269
+ # before Files.proxy changes.
270
+ Files::ApiClient.default_client
271
+
272
+ begin
273
+ Files::Transfer::Job.open(**transfer) do |job|
274
+ Files.proxy = proxy.url("")
275
+ Thread.current[:files_api_client_download_client] = Files::ApiClient.new(Files::ApiClient.build_default_conn(force_net_http: true))
276
+ remote_file(store).download_content(sink, transfer: job)
277
+ notes << job.diagnostics[:notes]
278
+ end
279
+ Files::Transfer::Job.open(**transfer) do |job|
280
+ remote_file(store).download_content(later, transfer: job)
281
+ notes << job.diagnostics[:notes]
282
+ end
283
+ ensure
284
+ Files.proxy = original
285
+ Thread.current[:files_api_client_download_client] = nil
286
+ end
287
+
288
+ expect([ sink.string, later.string ]).to eq([ payload, payload ])
289
+ # The job made before the change sent through the client, one request at a time; the one made after, over its own
290
+ # connections. Both went through the client's proxy, never straight to storage.
291
+ expect(notes.first).to include(a_string_including("one request at a time"))
292
+ expect(notes.last).to be_empty
293
+ expect(store.requests).to be_empty
294
+ expect(proxy.requests.map(&:target).uniq).to eq([ store.url("/objects/report.bin") ])
295
+ end
296
+
297
+ it "holds a download's opening request to an origin another's throttle holds back, through a client of its own as over the job's connections" do
298
+ Files.max_network_retries = 1
299
+ lock = Mutex.new
300
+ arrivals = []
301
+ answer = Queue.new
302
+ store = storage_server(answer: lambda { |_request, default|
303
+ first = lock.synchronize { (arrivals << Process.clock_gettime(Process::CLOCK_MONOTONIC)).size == 1 }
304
+ next default unless first
305
+
306
+ # The first download's opening request is answered only once the second's waits for the job's one permit.
307
+ answer.pop
308
+ TransferServer.response("503 Slow Down", { "Retry-After" => "1", "Content-Length" => 0 })
309
+ }
310
+ )
311
+ sinks = Array.new(3) { StringIO.new("".b) }
312
+
313
+ Files::Transfer::Job.open(**transfer, concurrency: 1, max_files: 3) do |job|
314
+ # The first download stops just after its throttled opening request gives the permit back, until the second
315
+ # download's opening request reaches storage or half a second has passed: a throttle published only after the
316
+ # permit was given back would let that waiting request through first.
317
+ settling = true
318
+ job.define_singleton_method(:release_request) do |*args|
319
+ super(*args).tap do
320
+ next unless settling
321
+
322
+ settling = false
323
+ store.wait_for_requests(2, timeout: 0.5)
324
+ end
325
+ end
326
+ # Over the job's own connections, the first download's opening request takes the one permit.
327
+ owned = Thread.new { remote_file(store).download_content(sinks[0], transfer: job) }
328
+ store.wait_for_requests(1)
329
+ # The second reserves its opening range and waits for that permit before the first is throttled.
330
+ waiting = Thread.new { remote_file(store).download_content(sinks[1], transfer: job) }
331
+ wait_until { job.held_bytes == 131_072 && waiting.status == "sleep" }
332
+ answer << :go
333
+ wait_until { job.diagnostics[:throttled].any? }
334
+ # The third, through a client of its own and so another controller, waits that second out as well.
335
+ through_client = Thread.new do
336
+ Thread.current[:files_api_client_download_client] = own_client
337
+ remote_file(store).download_content(sinks[2], transfer: job)
338
+ end
339
+ [ owned, waiting, through_client ].each(&:join)
340
+ end
341
+
342
+ expect(sinks.map(&:string)).to eq([ payload, payload, payload ])
343
+ expect(arrivals.size).to be > 3
344
+ expect(arrivals.drop(1).map { |at| at - arrivals.first }).to all(be >= 0.9)
345
+ end
346
+
347
+ it "ends at once, when cancelled, a download's wait for an origin another's throttle holds back" do
348
+ store = storage_server(answer: ->(_request, _default) { TransferServer.response("503 Slow Down", { "Retry-After" => "60", "Content-Length" => 0 }) })
349
+ control = Files::Transfer::Control.new
350
+ started = nil
351
+
352
+ Files::Transfer::Job.open(**transfer, max_files: 2, control: control) do |job|
353
+ download = lambda do
354
+ Thread.new do
355
+ remote_file(store).download_content(StringIO.new("".b), transfer: job)
356
+ rescue StandardError => e
357
+ e
358
+ end
359
+ end
360
+ throttled = download.call
361
+ wait_until { job.diagnostics[:throttled].any? }
362
+ waiting = download.call
363
+ sleep 0.3 # the second is waiting out the first one's minute
364
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
+ control.cancel
366
+
367
+ expect(throttled.value).to be_a(Files::APIError).and(have_attributes(http_status: 503))
368
+ expect(waiting.value).to be_a(Files::Transfer::CancelledError)
369
+ end
370
+
371
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
372
+ expect(store.requests.size).to eq(1)
373
+ end
374
+
375
+ it "keeps the opening requests of a job's downloads within its request limit" do
376
+ small = payload.byteslice(0, 1000)
377
+ store = storage_server(small, answer: lambda { |_request, default|
378
+ sleep 0.3
379
+ default
380
+ }
381
+ )
382
+ sinks = Array.new(2) { StringIO.new("".b) }
383
+
384
+ Files::Transfer::Job.open(concurrency: 1, max_files: 2, buffer_bytes: 262_144) do |job|
385
+ sinks.map { |io| Thread.new { remote_file(store).download_content(io, transfer: job) } }.each(&:join)
386
+ end
387
+
388
+ expect(sinks.map(&:string)).to eq([ small, small ])
389
+ expect(store.requests.size).to eq(2)
390
+ expect(store.peak_in_flight).to eq(1)
391
+ end
392
+
393
+ it "counts a range as on the network until its whole body has arrived, so a body slower than its headers is judged" do
394
+ content = Random.new(5).bytes(20 * 65_536)
395
+ store = storage_server(content, answer: lambda { |request, default|
396
+ next default unless request.headers["if-match"]
397
+
398
+ head, body = default.split("\r\n\r\n", 2)
399
+ [ "#{head}\r\n\r\n", -> { sleep 0.05 }, body ]
400
+ }
401
+ )
402
+
403
+ Files::Transfer::Job.open(**transfer, max_concurrency: 2) do |job|
404
+ remote_file(store).download_content(sink, transfer: job)
405
+
406
+ # Nineteen ranges two at a time, each body held 50 ms after its headers: one full window, with both on the
407
+ # network nearly all of it.
408
+ controller = job.diagnostics[:controllers].first
409
+ expect(controller.values_at(:judged_windows, :ineligible_windows)).to eq([ 1, 0 ])
410
+ end
411
+ expect(sink.string).to eq(content)
412
+ end
413
+
414
+ it "renews an expired download URL with the context the download began with, whatever changed since" do
415
+ store = storage_server
416
+ lookups = []
417
+ api = TransferServer.new(concurrent: true) do |request|
418
+ first = lookups.empty?
419
+ lookups << request
420
+ if first
421
+ Files.api_key = "changed-api-key"
422
+ Files.session_id = "changed-session"
423
+ Files.base_url = "http://127.0.0.1:1"
424
+ Files.workspace_id = 9
425
+ end
426
+ uri = store.url(first ? "/objects/report.bin?X-Amz-Date=20000101T000000Z&X-Amz-Expires=60" : "/objects/report.bin")
427
+ body = JSON.generate(path: "report.bin", type: "file", download_uri: uri)
428
+ TransferServer.response("200 OK", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
429
+ end
430
+ servers << api
431
+ workspace = Files.workspace_id
432
+ Files.base_url = api.url("")
433
+ Files.workspace_id = 7
434
+
435
+ begin
436
+ described_class.new(path: "report.bin").download_content(sink, transfer: transfer)
437
+ ensure
438
+ Files.workspace_id = workspace
439
+ end
440
+
441
+ expect(sink.string).to eq(payload)
442
+ expect(lookups.size).to eq(2)
443
+ expect(lookups.map { |request| request.headers.values_at("x-filesapi-auth", "x-filesapi-key", "x-files-workspace-id") }.uniq)
444
+ .to eq([ [ "fixture-session", nil, "7" ] ])
445
+ end
446
+
447
+ it "sends a range a worker takes up after a renewal to the origin it was admitted for, never past the new origin's throttle nor holding the old one back for it" do
448
+ Files.max_network_retries = 1
449
+ expiring_at = Time.now + 1.5
450
+ # The first origin answers a range after the opening one only once its URL is within the renewal margin, so the
451
+ # download renews it, on to the second origin, before admitting another.
452
+ origin_a = storage_server(answer: lambda { |request, default|
453
+ sleep 0.01 while !request.headers["range"].start_with?("bytes=0-") && Time.now <= expiring_at + 0.1
454
+ default
455
+ }
456
+ )
457
+ lock = Mutex.new
458
+ arrivals = []
459
+ # The second throttles its first request for a second, and any request that comes before that second is nearly
460
+ # over.
461
+ origin_b = storage_server(answer: lambda { |_request, default|
462
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
463
+ early = lock.synchronize { (arrivals << now).size == 1 || now - arrivals.first < 0.9 }
464
+ early ? TransferServer.response("503 Slow Down", { "Retry-After" => "1", "Content-Length" => 0 }) : default
465
+ }
466
+ )
467
+ api = api_server(origin_b)
468
+ record = described_class.new(path: "report.bin", download_uri: origin_a.url("/objects/report.bin?se=#{(expiring_at + 5).utc.iso8601(3)}"))
469
+ release = Queue.new
470
+ held = []
471
+ throttled = nil
472
+
473
+ Files::Transfer::Job.open(**transfer, concurrency: 2) do |job|
474
+ # The first range admitted, to the first origin, reaches its worker only once the test lets it: after the URL
475
+ # was renewed and the second origin's throttle holds it back.
476
+ gated = false
477
+ job.define_singleton_method(:submit) do |&work|
478
+ next super(&work) if gated
479
+
480
+ gated = true
481
+ super() do |connections|
482
+ release.pop
483
+ work.call(connections)
484
+ end
485
+ end
486
+ job.define_singleton_method(:hold) do |url, seconds|
487
+ held << URI(url).port
488
+ super(url, seconds)
489
+ end
490
+ download = Thread.new { record.download_content(sink, transfer: job) }
491
+ begin
492
+ wait_until { job.diagnostics[:throttled].any? }
493
+ throttled = job.diagnostics[:throttled].keys
494
+ ensure
495
+ release << :go
496
+ end
497
+ download.join
498
+ end
499
+
500
+ expect(sink.string).to eq(payload)
501
+ expect(api.requests.size).to eq(1)
502
+ expect(throttled).to eq([ origin_b.url("") ])
503
+ expect(ranges(origin_a)).to include("bytes=65536-131071")
504
+ expect(ranges(origin_b)).not_to include("bytes=65536-131071")
505
+ expect(held.uniq).to eq([ URI(origin_b.url("")).port ])
506
+ expect(arrivals.drop(1).map { |at| at - arrivals.first }).to all(be >= 0.9)
507
+ end
508
+
509
+ it "renews and sends again a range storage refused for its expired URL while the renewal it overlaps is unpublished, writing each range once" do
510
+ Files.max_network_retries = 1
511
+ expiring_at = Time.now + 1.5
512
+ refused = []
513
+ # The first origin answers the opening range at once, and a later range that arrives before its URL is within the
514
+ # renewal margin once it is. A range that arrives after that is refused 403, as storage refuses an expired URL.
515
+ origin_a = storage_server(answer: lambda { |request, default|
516
+ range = request.headers["range"]
517
+ next default if range.start_with?("bytes=0-")
518
+
519
+ if Time.now > expiring_at
520
+ refused << range
521
+ next TransferServer.response("403 Forbidden", { "Content-Length" => 0 })
522
+ end
523
+ sleep 0.01 while Time.now <= expiring_at + 0.1
524
+ default
525
+ }
526
+ )
527
+ origin_b = storage_server
528
+ api = api_server(origin_b)
529
+ record = described_class.new(path: "report.bin", download_uri: origin_a.url("/objects/report.bin?se=#{(expiring_at + 5).utc.iso8601(3)}"))
530
+ writes = []
531
+ recorder = StringIO.new("".b)
532
+ recorder.define_singleton_method(:<<) do |data|
533
+ writes << data.bytesize
534
+ super(data)
535
+ end
536
+ release = Queue.new
537
+ at_publication = Queue.new
538
+ publish = Queue.new
539
+ renewing = false
540
+ expiry_known = false
541
+ paused = false
542
+ stopped = nil
543
+ # The renewal stops on its first line after it has the replacement URL's expiry, before it publishes the
544
+ # replacement, until the range admitted before it has had storage's 403 for its expired URL and settled it. Judged
545
+ # by the expiry of the URL being published beside the generation of the one still published, that 403 is refused.
546
+ expiry = TracePoint.new(:return) { expiry_known = true if renewing }
547
+ step = TracePoint.new(:line) do
548
+ renewing = true
549
+ next unless expiry_known && !paused
550
+
551
+ paused = true
552
+ at_publication << :renewing
553
+ publish.pop
554
+ end
555
+
556
+ Files::Transfer::Job.open(**transfer, concurrency: 2) do |job|
557
+ # The first range admitted, to the first origin, reaches its worker only once the renewal has stopped.
558
+ gated = false
559
+ job.define_singleton_method(:submit) do |&work|
560
+ next super(&work) if gated
561
+
562
+ gated = true
563
+ super() do |connections|
564
+ release.pop
565
+ work.call(connections)
566
+ end
567
+ end
568
+ expiry.enable(target: Files::Transfer::StorageRetry.method(:expires_at))
569
+ step.enable(target: Files::Transfer::DownloadCoordinator.instance_method(:renew_url))
570
+ begin
571
+ download = Thread.new { record.download_content(recorder, transfer: job) }
572
+ # At most five seconds for the renewal to stop, without Queue#pop's timeout, which Ruby before 3.2 lacks: its
573
+ # signal is taken only once it has arrived, and stopped stays nil when it never does.
574
+ wait_until { !at_publication.empty? }
575
+ stopped = at_publication.pop(true) unless at_publication.empty?
576
+ release << :go
577
+ wait_until { origin_a.requests.size == 3 && job.diagnostics[:requests].zero? }
578
+ publish << :go
579
+ download.join
580
+ ensure
581
+ release << :go
582
+ publish << :go
583
+ step.disable
584
+ expiry.disable
585
+ end
586
+ expect(job.held_bytes).to eq(0)
587
+ expect(job.diagnostics.values_at(:accepted_bytes, :retries)).to eq([ payload.bytesize, 1 ])
588
+ end
589
+
590
+ expect(stopped).to eq(:renewing)
591
+ expect(refused).to eq([ "bytes=65536-131071" ])
592
+ expect(api.requests.size).to eq(1)
593
+ expect(ranges(origin_b).count("bytes=65536-131071")).to eq(1)
594
+ expect(recorder.string).to eq(payload)
595
+ expect(writes).to eq(([ 65_536 ] * 10) + [ 1000 ])
596
+ expect(recorder).not_to be_closed
597
+ end
598
+ end
599
+
600
+ describe "without a bound version" do
601
+ it "streams the whole response once when the opening range has a weak ETag" do
602
+ store = storage_server(etag: "W/\"v1\"")
603
+
604
+ remote_file(store).download_content(sink, transfer: transfer)
605
+
606
+ expect(sink.string).to eq(payload)
607
+ expect(ranges(store)).to eq([ "bytes=0-65535", nil ])
608
+ end
609
+
610
+ it "streams a whole response storage sent for the opening range" do
611
+ store = storage_server(answer: ->(_request, _default) { TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, payload) })
612
+
613
+ remote_file(store).download_content(sink, transfer: transfer)
614
+
615
+ expect(sink.string).to eq(payload)
616
+ expect(store.requests.size).to eq(1)
617
+ end
618
+
619
+ it "writes nothing for an empty file" do
620
+ store = storage_server(answer: ->(_request, _default) { TransferServer.response("416 Range Not Satisfiable", { "Content-Range" => "bytes */0", "Content-Length" => 0 }) })
621
+
622
+ remote_file(store).download_content(sink, transfer: transfer)
623
+
624
+ expect(sink.string).to be_empty
625
+ expect(sink).not_to be_closed
626
+ end
627
+
628
+ it "starts a local file over from its first byte when a whole response ends early" do
629
+ Files.max_network_retries = 1
630
+ lock = Mutex.new
631
+ sent = 0
632
+ store = storage_server(answer: lambda { |_request, _default|
633
+ first = lock.synchronize { (sent += 1) == 1 }
634
+ TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, first ? payload.byteslice(0, 4096) : payload)
635
+ }
636
+ )
637
+
638
+ Dir.mktmpdir do |dir|
639
+ local_path = File.join(dir, "report.bin")
640
+ remote_file(store).download_file(local_path, transfer: transfer)
641
+
642
+ expect(File.binread(local_path)).to eq(payload)
643
+ end
644
+ expect(store.requests.size).to eq(2)
645
+ end
646
+
647
+ it "raises instead of writing bytes twice into an IO that cannot start over" do
648
+ Files.max_network_retries = 1
649
+ store = storage_server(answer: ->(_request, _default) { TransferServer.response("200 OK", { "Content-Length" => payload.bytesize }, payload.byteslice(0, 4096)) })
650
+ written = "".b
651
+ append_only = Object.new
652
+ append_only.define_singleton_method(:<<) { |chunk| written << chunk }
653
+
654
+ expect { remote_file(store).download_content(append_only, transfer: transfer) }
655
+ .to raise_error(EOFError, "Transfer response ended after 4096 of #{payload.bytesize} bytes")
656
+ expect(written).to eq(payload.byteslice(0, 4096))
657
+ expect(store.requests.size).to eq(1)
658
+ end
659
+
660
+ it "keeps what a whole response wrote when it is cancelled" do
661
+ store = storage_server(etag: "W/\"v1\"")
662
+ control = Files::Transfer::Control.new
663
+ cancel = ->(report) { control.cancel if report[:written_bytes] > 65_536 }
664
+
665
+ Dir.mktmpdir do |dir|
666
+ local_path = File.join(dir, "report.bin")
667
+ expect { remote_file(store).download_file(local_path, progress: cancel, transfer: transfer.merge(control: control)) }
668
+ .to raise_error(Files::Transfer::CancelledError)
669
+
670
+ kept = File.binread(local_path)
671
+ expect(kept.bytesize).to be > 0
672
+ expect(kept).to eq(payload.byteslice(0, kept.bytesize))
673
+ end
674
+ expect(ranges(store)).to eq([ "bytes=0-65535", nil ])
675
+ end
676
+
677
+ it "calls progress for a whole response in the ApiClient#request scope it was called from, through a download_client as over the job's own connections" do
678
+ whole = storage_server(etag: "W/\"v1\"")
679
+ scope = Files::ApiClient.new(Files::ApiClient.build_default_conn, workspace_id: 42)
680
+ seen = []
681
+ download = lambda do
682
+ io = StringIO.new("".b)
683
+ scope.request { remote_file(whole).download_content(io, progress: ->(_report) { seen << Files::ApiClient.active_client }, transfer: transfer) }
684
+ expect(io.string).to eq(payload)
685
+ end
686
+
687
+ download.call
688
+ Thread.current[:files_api_client_download_client] = own_client
689
+ begin
690
+ download.call
691
+ ensure
692
+ Thread.current[:files_api_client_download_client] = nil
693
+ end
694
+
695
+ expect(seen.size).to be > 2
696
+ expect(seen.uniq).to eq([ scope ])
697
+ expect(ranges(whole)).to eq([ "bytes=0-65535", nil, "bytes=0-65535", nil ])
698
+ end
699
+ end
700
+
701
+ describe "refusals" do
702
+ it "refuses a response from the Files.com web app before writing any of it, and closes the IO" do
703
+ page = "<html>Sign in</html>"
704
+ store = storage_server(answer: lambda { |_request, _default|
705
+ TransferServer.response("206 Partial Content", { "X-Files-Frontend-App" => "true", "ETag" => "\"v1\"", "Content-Range" => "bytes 0-19/20", "Content-Length" => 20 }, page)
706
+ }
707
+ )
708
+
709
+ expect { remote_file(store).download_content(sink, transfer: transfer) }.to raise_error(Files::Transfer::FrontendResponseError)
710
+ expect(sink.string).to be_empty
711
+ expect(sink).to be_closed
712
+ end
713
+
714
+ it "refuses a range combined with transfer" do
715
+ expect { remote_file(storage_server).download_content(sink, range: [ 0, 9 ], transfer: transfer) }.to raise_error(Files::InvalidParameterError)
716
+ end
717
+
718
+ it "raises a cancellation that came before its opening range was refused, leaving the IO open" do
719
+ control = Files::Transfer::Control.new
720
+ store = storage_server(answer: lambda { |_request, _default|
721
+ control.cancel
722
+ TransferServer.response("403 Forbidden", { "Content-Length" => 0 })
723
+ }
724
+ )
725
+
726
+ expect { remote_file(store).download_content(sink, transfer: transfer.merge(control: control)) }.to raise_error(Files::Transfer::CancelledError)
727
+ expect(sink).not_to be_closed
728
+ end
729
+
730
+ it "raises a refusal that came before a cancellation, made here as the refusal closes the IO" do
731
+ control = Files::Transfer::Control.new
732
+ closing = Class.new(StringIO) do
733
+ attr_accessor :on_close
734
+
735
+ def close
736
+ super
737
+ on_close&.call
738
+ end
739
+ end.new("".b)
740
+ closing.on_close = -> { control.cancel }
741
+ store = storage_server(answer: ->(_request, _default) { TransferServer.response("403 Forbidden", { "Content-Length" => 0 }) })
742
+
743
+ expect { remote_file(store).download_content(closing, transfer: transfer.merge(control: control)) }
744
+ .to raise_error(Files::APIError) { |error| expect(error.http_status).to eq(403) }
745
+ expect(closing).to be_closed
746
+ expect(control).to be_cancelled
747
+ end
748
+
749
+ it "raises a cancellation that came before the API refused to give the download URL, leaving the IO open" do
750
+ control = Files::Transfer::Control.new
751
+ api = TransferServer.new(concurrent: true) do |_request|
752
+ control.cancel
753
+ body = { error: "Not Found", 'http-code': 404, type: "not-found" }.to_json
754
+ TransferServer.response("404 Not Found", { "Content-Type" => "application/json", "Content-Length" => body.bytesize }, body)
755
+ end
756
+ servers << api
757
+ Files.base_url = api.url("")
758
+
759
+ expect { described_class.new(path: "report.bin").download_content(sink, transfer: transfer.merge(control: control)) }
760
+ .to raise_error(Files::Transfer::CancelledError)
761
+ expect(sink).not_to be_closed
762
+ end
763
+
764
+ it "raises a cancellation its progress callback made on the last write, not a success" do
765
+ small = payload.byteslice(0, 1000)
766
+ control = Files::Transfer::Control.new
767
+ store = storage_server(small)
768
+
769
+ expect { remote_file(store).download_content(sink, progress: ->(_report) { control.cancel }, transfer: transfer.merge(control: control)) }
770
+ .to raise_error(Files::Transfer::CancelledError)
771
+ expect(sink.string).to eq(small)
772
+ expect(sink).not_to be_closed
773
+ end
774
+ end
775
+
776
+ describe "pause and resume" do
777
+ # Storage that pauses the returned control when the range after the opening one arrives, then answers it, or
778
+ # passes it to answer.
779
+ def pause_after_first_range(answer = nil)
780
+ control = Files::Transfer::Control.new
781
+ store = storage_server(answer: lambda { |request, default|
782
+ control.pause if request.headers["range"] == "bytes=65536-131071"
783
+ answer ? answer.call(request, default) : default
784
+ }
785
+ )
786
+ [ store, control ]
787
+ end
788
+
789
+ it "pauses with a checkpoint that resumes the same local file from where it stopped, with If-Match, and pauses a resume with a checkpoint of all the bytes written" do
790
+ resume_control = Files::Transfer::Control.new
791
+ # The first resume is paused in turn when the range after its own opening one arrives.
792
+ store, control = pause_after_first_range(lambda { |request, default|
793
+ resume_control.pause if request.headers["range"] == "bytes=196608-262143"
794
+ default
795
+ }
796
+ )
797
+ api_server(store)
798
+
799
+ Dir.mktmpdir do |dir|
800
+ local_path = File.join(dir, "report.bin")
801
+ paused = nil
802
+ expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
803
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
804
+ checkpoint = paused.checkpoint
805
+ expect([ checkpoint.direction, checkpoint.completed_bytes, checkpoint.total_bytes, checkpoint.version ]).to eq([ :download, 131_072, payload.bytesize, "\"v1\"" ])
806
+ expect(checkpoint.inspect).not_to include("/objects/")
807
+ expect(File.binread(local_path)).to eq(payload.byteslice(0, 131_072))
808
+ expect(checkpoint.state[:digest]).to eq(Digest::SHA256.hexdigest(payload.byteslice(0, 131_072)))
809
+ resumed_from = store.requests.size
810
+
811
+ # The resume checks the bytes written against that digest, and its own checkpoint's digest goes on from it over
812
+ # the bytes the resume wrote.
813
+ paused_again = nil
814
+ expect { described_class.resume_download(checkpoint, nil, {}, transfer: transfer.merge(concurrency: 1, control: resume_control)) }
815
+ .to raise_error(Files::Transfer::PausedError) { |error| paused_again = error }
816
+ checkpoint_again = paused_again.checkpoint
817
+ expect([ checkpoint_again.direction, checkpoint_again.completed_bytes, checkpoint_again.total_bytes, checkpoint_again.version ])
818
+ .to eq([ :download, 262_144, payload.bytesize, "\"v1\"" ])
819
+ expect(File.binread(local_path)).to eq(payload.byteslice(0, 262_144))
820
+ expect(checkpoint_again.state[:digest]).to eq(Digest::SHA256.hexdigest(payload.byteslice(0, 262_144)))
821
+ resumed_again_from = store.requests.size
822
+
823
+ described_class.resume_download(checkpoint_again, nil, {}, transfer: transfer)
824
+
825
+ expect(File.binread(local_path)).to eq(payload)
826
+ expect(store.requests.drop(resumed_from).map { |request| request.headers["if-match"] }.uniq).to eq([ "\"v1\"" ])
827
+ expect(store.requests.drop(resumed_from).first.headers["range"]).to eq("bytes=131072-196607")
828
+ expect(store.requests.drop(resumed_again_from).first.headers["range"]).to eq("bytes=262144-327679")
829
+ end
830
+ end
831
+
832
+ it "refuses a checkpoint whose local file was replaced, and stops when the remote version changed" do
833
+ changed = false
834
+ store, control = pause_after_first_range(lambda { |request, default|
835
+ changed && request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default
836
+ }
837
+ )
838
+ api_server(store)
839
+
840
+ Dir.mktmpdir do |dir|
841
+ local_path = File.join(dir, "report.bin")
842
+ paused = nil
843
+ expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
844
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
845
+
846
+ File.rename(local_path, "#{local_path}.moved")
847
+ File.binwrite(local_path, payload.byteslice(0, 131_072))
848
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
849
+
850
+ File.rename("#{local_path}.moved", local_path)
851
+ changed = true
852
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
853
+ end
854
+ end
855
+
856
+ it "resumes into the same IO only" do
857
+ store, control = pause_after_first_range
858
+ api_server(store)
859
+ paused = nil
860
+
861
+ expect { remote_file(store).download_content(sink, transfer: transfer.merge(concurrency: 1, control: control)) }
862
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
863
+ expect(sink).not_to be_closed
864
+ expect { described_class.resume_download(paused.checkpoint, StringIO.new("".b), {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
865
+
866
+ described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer)
867
+ expect(sink.string).to eq(payload)
868
+ expect { described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
869
+ end
870
+
871
+ it "refuses to resume over bytes changed in the same local file or IO, changing nothing" do
872
+ store, control = pause_after_first_range
873
+ api_server(store)
874
+
875
+ Dir.mktmpdir do |dir|
876
+ local_path = File.join(dir, "report.bin")
877
+ paused = nil
878
+ expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
879
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
880
+ edited = payload.byteslice(0, 131_072).dup
881
+ edited.setbyte(10, edited.getbyte(10) ^ 0xff)
882
+ File.open(local_path, "r+b") { |file| file.write(edited) }
883
+ resumed_from = store.requests.size
884
+
885
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
886
+ expect(File.binread(local_path)).to eq(edited)
887
+ expect(store.requests.size).to eq(resumed_from)
888
+ end
889
+
890
+ store, control = pause_after_first_range
891
+ api_server(store)
892
+ paused = nil
893
+ expect { remote_file(store).download_content(sink, transfer: transfer.merge(concurrency: 1, control: control)) }
894
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
895
+ sink.string.setbyte(0, sink.string.getbyte(0) ^ 0xff)
896
+ edited = sink.string.dup
897
+
898
+ expect { described_class.resume_download(paused.checkpoint, sink, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
899
+ expect(sink.string).to eq(edited)
900
+ end
901
+
902
+ it "raises a pause made while a resume reads its bytes back, not the change it finds after, leaving the checkpoint unspent" do
903
+ store, control = pause_after_first_range
904
+ api_server(store)
905
+ io = Tempfile.new("barrier").tap(&:binmode)
906
+ reading = Queue.new
907
+ proceed = Queue.new
908
+ barrier = nil
909
+ recorder = SimpleDelegator.new(io)
910
+ recorder.define_singleton_method(:pread) do |length, offset|
911
+ if barrier
912
+ gate = barrier
913
+ barrier = nil
914
+ gate.call
915
+ end
916
+ io.pread(length, offset)
917
+ end
918
+
919
+ paused = nil
920
+ expect { remote_file(store).download_content(recorder, transfer: transfer.merge(concurrency: 1, control: control)) }
921
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
922
+ io.pwrite((io.pread(1, 0).ord ^ 0xff).chr, 0)
923
+ edited = io.pread(131_072, 0)
924
+ resumed_from = store.requests.size
925
+
926
+ # The resume's first read-back waits here; it is paused meanwhile, and then finds the changed byte.
927
+ barrier = lambda do
928
+ reading << :reading
929
+ proceed.pop
930
+ end
931
+ resume_control = Files::Transfer::Control.new
932
+ resume = Thread.new do
933
+ described_class.resume_download(paused.checkpoint, recorder, {}, transfer: transfer.merge(control: resume_control))
934
+ rescue StandardError => e
935
+ e
936
+ end
937
+ reading.pop
938
+ resume_control.pause
939
+ proceed << :go
940
+
941
+ error = resume.value
942
+ expect(error).to be_a(Files::Transfer::PausedError)
943
+ expect(error.checkpoint).to equal(paused.checkpoint)
944
+ expect(io.pread(131_072, 0)).to eq(edited)
945
+ expect(store.requests.size).to eq(resumed_from)
946
+ # Unspent: the next resume checks the bytes again, and refuses them as changed.
947
+ expect { described_class.resume_download(paused.checkpoint, recorder, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /changed/)
948
+ ensure
949
+ io&.close!
950
+ end
951
+
952
+ it "stops a resumed download that storage answers whole with another version, before truncating anything" do
953
+ other = false
954
+ store, control = pause_after_first_range(lambda { |_request, default|
955
+ other ? TransferServer.response("200 OK", { "ETag" => "\"v2\"", "Content-Length" => payload.bytesize }, payload.reverse) : default
956
+ }
957
+ )
958
+ api_server(store)
959
+
960
+ Dir.mktmpdir do |dir|
961
+ local_path = File.join(dir, "report.bin")
962
+ paused = nil
963
+ expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
964
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
965
+ File.open(local_path, "ab") { |file| file.write("written after the pause") }
966
+ other = true
967
+
968
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
969
+ expect(File.binread(local_path)).to eq("#{payload.byteslice(0, 131_072)}written after the pause".b)
970
+ # That resume started, so its failure spent the checkpoint.
971
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError, /already been used/)
972
+ end
973
+ end
974
+
975
+ it "reads back the bytes a Tempfile holds to check them before resuming into it" do
976
+ kept = Tempfile.new("kept").tap(&:binmode)
977
+ edited = Tempfile.new("edited").tap(&:binmode)
978
+
979
+ checkpoints = [ kept, edited ].map do |io|
980
+ store, control = pause_after_first_range
981
+ api_server(store)
982
+ paused = nil
983
+ expect { remote_file(store).download_content(io, transfer: transfer.merge(concurrency: 1, control: control)) }
984
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
985
+ paused.checkpoint
986
+ end
987
+ File.open(edited.path, "r+b") { |file| file.write("x") }
988
+
989
+ expect { described_class.resume_download(checkpoints[1], edited, {}, transfer: transfer) }.to raise_error(Files::Transfer::CheckpointError)
990
+ described_class.resume_download(checkpoints[0], kept, {}, transfer: transfer)
991
+ kept.rewind
992
+ expect(kept.read).to eq(payload)
993
+ ensure
994
+ [ kept, edited ].each { |io| io&.close! }
995
+ end
996
+
997
+ it "gives no checkpoint for a bound download into an IO that cannot say where its bytes began" do
998
+ store, control = pause_after_first_range
999
+ written = "".b
1000
+ append_only = Object.new
1001
+ append_only.define_singleton_method(:<<) { |chunk| written << chunk }
1002
+
1003
+ expect { remote_file(store).download_content(append_only, transfer: transfer.merge(concurrency: 1, control: control)) }
1004
+ .to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
1005
+ expect(written).to eq(payload.byteslice(0, 131_072))
1006
+ end
1007
+
1008
+ it "gives no checkpoint, and sends nothing, for a download paused while its local file opens" do
1009
+ store = storage_server
1010
+ control = Files::Transfer::Control.new
1011
+ # Opening the local file pauses the download and then fails: the pause came first, with nothing opened to resume.
1012
+ local_path = Object.new
1013
+ local_path.define_singleton_method(:to_path) do
1014
+ control.pause
1015
+ "/nonexistent/report.bin"
1016
+ end
1017
+
1018
+ Files::Transfer::Job.open(**transfer, control: control) do |job|
1019
+ expect { remote_file(store).download_file(local_path, transfer: job) }
1020
+ .to raise_error(Files::Transfer::PausedError) { |error| expect(error.checkpoint).to be_nil }
1021
+ expect(job.diagnostics.values_at(:requests, :held_bytes)).to eq([ 0, 0 ])
1022
+ end
1023
+ expect(store.requests).to be_empty
1024
+ end
1025
+
1026
+ it "has storage prove the version of a download paused with every byte written before dropping what follows them" do
1027
+ last = payload.bytesize - 1
1028
+ [ false, true ].each do |changed|
1029
+ other = false
1030
+ control = Files::Transfer::Control.new
1031
+ store = storage_server(answer: lambda { |request, default|
1032
+ control.pause if request.headers["range"] == "bytes=655360-#{last}"
1033
+ other && request.headers["if-match"] ? TransferServer.response("412 Precondition Failed", { "Content-Length" => 0 }) : default
1034
+ }
1035
+ )
1036
+ api_server(store)
1037
+
1038
+ Dir.mktmpdir do |dir|
1039
+ local_path = File.join(dir, "report.bin")
1040
+ paused = nil
1041
+ expect { remote_file(store).download_file(local_path, transfer: transfer.merge(concurrency: 1, control: control)) }
1042
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
1043
+ expect([ paused.checkpoint.completed_bytes, paused.checkpoint.total_bytes ]).to eq([ payload.bytesize, payload.bytesize ])
1044
+ File.open(local_path, "ab") { |file| file.write("written after the pause") }
1045
+ other = changed
1046
+ resumed_from = store.requests.size
1047
+
1048
+ if changed
1049
+ expect { described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer) }.to raise_error(Files::Transfer::VersionChangedError)
1050
+ expect(File.binread(local_path)).to eq("#{payload}written after the pause".b)
1051
+ else
1052
+ described_class.resume_download(paused.checkpoint, nil, {}, transfer: transfer)
1053
+ expect(File.binread(local_path)).to eq(payload)
1054
+ end
1055
+ expect(store.requests.drop(resumed_from).map { |request| request.headers.values_at("range", "if-match") }).to eq([ [ "bytes=#{last}-#{last}", "\"v1\"" ] ])
1056
+ end
1057
+ end
1058
+ end
1059
+
1060
+ it "reads more than 1 MiB of written bytes back in charged slices within a 64 KiB budget another download shares" do
1061
+ big = Random.new(11).bytes(1_048_576 + (4 * 16_384))
1062
+ control = Files::Transfer::Control.new
1063
+ store = storage_server(big, answer: lambda { |request, default|
1064
+ control.pause if request.headers["range"] == "bytes=1064960-1081343"
1065
+ default
1066
+ }
1067
+ )
1068
+ api_server(store)
1069
+ io = Tempfile.new("prefix").tap(&:binmode)
1070
+ reads = []
1071
+ recorder = SimpleDelegator.new(io)
1072
+ recorder.define_singleton_method(:pread) do |length, offset|
1073
+ reads << length
1074
+ io.pread(length, offset)
1075
+ end
1076
+ budget = { buffer_bytes: 65_536 }
1077
+
1078
+ paused = nil
1079
+ expect { remote_file(store).download_content(recorder, transfer: budget.merge(concurrency: 1, control: control)) }
1080
+ .to raise_error(Files::Transfer::PausedError) { |error| paused = error }
1081
+ expect(paused.checkpoint.completed_bytes).to eq(1_081_344)
1082
+
1083
+ small = payload.byteslice(0, 40_000)
1084
+ neighbour_sink = StringIO.new("".b)
1085
+ neighbour_store = storage_server(small)
1086
+ Files::Transfer::Job.open(**budget, max_files: 2) do |job|
1087
+ neighbour = Thread.new { remote_file(neighbour_store).download_content(neighbour_sink, transfer: job) }
1088
+ described_class.resume_download(paused.checkpoint, recorder, {}, transfer: job)
1089
+ neighbour.join
1090
+ expect(job.held_bytes).to eq(0)
1091
+ end
1092
+
1093
+ expect(reads.sum).to eq(1_081_344)
1094
+ expect(reads.max).to eq(16_384)
1095
+ io.rewind
1096
+ expect(io.read).to eq(big)
1097
+ expect(neighbour_sink.string).to eq(small)
1098
+ ensure
1099
+ io&.close!
1100
+ end
1101
+ end
1102
+ end