Compare commits

...

5 Commits

Author SHA1 Message Date
Robin Appelman 8a5c50a313 test: add test that aborted uploads don't overwrite existing content
Signed-off-by: Robin Appelman <robin@icewind.nl>
2025-08-04 17:42:15 +02:00
Robin Appelman fee6a56339 fix: fix check if we can use a part file
Signed-off-by: Robin Appelman <robin@icewind.nl>
2025-08-04 17:37:49 +02:00
Robin Appelman 8ab626bedf fix: better object store write error propagation
Signed-off-by: Robin Appelman <robin@icewind.nl>
2025-08-04 17:34:46 +02:00
Robin Appelman 168a58eaeb fix: always do stream counting for object store upload
Signed-off-by: Robin Appelman <robin@icewind.nl>
2025-08-04 17:34:46 +02:00
Robin Appelman 1fdece8fce fix: validate written size for s3 multipart uploads
Signed-off-by: Robin Appelman <robin@icewind.nl>
2025-08-04 17:34:44 +02:00
4 changed files with 96 additions and 32 deletions
+11 -7
View File
@@ -133,8 +133,12 @@ class File extends Node implements IFile {
$transferId = \rand();
// mark file as partial while uploading (ignored by the scanner)
$partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part';
$partParentPath = dirname($partFilePath);
if ($partParentPath === '.') {
$partParentPath = '';
}
if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) {
if (!$view->isCreatable($partParentPath) && $view->isUpdatable($this->path)) {
$needsPartFile = false;
}
}
@@ -204,6 +208,9 @@ class File extends Node implements IFile {
}
}
$lengthHeader = $this->request->getHeader('content-length');
$expected = $lengthHeader !== '' ? (int)$lengthHeader : null;
if ($partStorage->instanceOfStorage(IWriteStreamStorage::class)) {
$isEOF = false;
$wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF): void {
@@ -215,7 +222,7 @@ class File extends Node implements IFile {
$count = -1;
try {
/** @var IWriteStreamStorage $partStorage */
$count = $partStorage->writeStream($internalPartPath, $wrappedData);
$count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected);
} catch (GenericFileException $e) {
$logger = Server::get(LoggerInterface::class);
$logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']);
@@ -235,10 +242,7 @@ class File extends Node implements IFile {
[$count, $result] = Files::streamCopy($data, $target, true);
fclose($target);
}
$lengthHeader = $this->request->getHeader('content-length');
$expected = $lengthHeader !== '' ? (int)$lengthHeader : -1;
if ($result === false && $expected >= 0) {
if ($result === false && $expected !== null) {
throw new Exception(
$this->l10n->t(
'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)',
@@ -253,7 +257,7 @@ class File extends Node implements IFile {
// if content length is sent by client:
// double check if the file was fully received
// compare expected and actual size
if ($expected >= 0
if ($expected !== null
&& $expected !== $count
&& $this->request->getMethod() === 'PUT'
) {
@@ -668,6 +668,41 @@ class FileTest extends TestCase {
}
public function testUploadAbortOverwrite(): void {
$view = Filesystem::getView();
$view->file_put_contents('test.txt', 'old content');
$request = new Request([
'server' => [
'CONTENT_LENGTH' => '123456',
],
'method' => 'PUT',
], $this->requestId, $this->config, null);
$info = $view->getFileInfo('test.txt');
$file = new File($view, $info, null, $request);
// action
$thrown = false;
try {
// beforeMethod locks
$view->lockFile('test.txt', ILockingProvider::LOCK_SHARED);
$file->put($this->getStream('test data'));
} catch (\Sabre\DAV\Exception\BadRequest $e) {
$thrown = true;
} finally {
// afterMethod unlocks
$view->unlockFile('test.txt', ILockingProvider::LOCK_EXCLUSIVE);
}
$this->assertTrue($thrown);
$this->assertEquals('old_content', $view->file_get_contents('test.txt'));
$this->assertEmpty($this->listPartFiles($view, ''), 'No stray part files');
}
public function testDeleteWhenAllowed(): void {
// setup
/** @var View&MockObject */
@@ -475,6 +475,9 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFil
'original-storage' => $this->getId(),
'original-path' => $path,
];
if ($size) {
$metadata['size'] = $size;
}
$stat['mimetype'] = $mimetype;
$stat['etag'] = $this->getETag($path);
@@ -496,32 +499,27 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFil
$urn = $this->getURN($fileId);
try {
//upload to object storage
if ($size === null) {
$countStream = CountWrapper::wrap($stream, function ($writtenSize) use ($fileId, &$size) {
$totalWritten = 0;
$countStream = CountWrapper::wrap($stream, function ($writtenSize) use ($fileId, $size, $exists, &$totalWritten) {
if (is_null($size) && !$exists) {
$this->getCache()->update($fileId, [
'size' => $writtenSize,
]);
$size = $writtenSize;
});
if ($this->objectStore instanceof IObjectStoreMetaData) {
$this->objectStore->writeObjectWithMetaData($urn, $countStream, $metadata);
} else {
$this->objectStore->writeObject($urn, $countStream, $metadata['mimetype']);
}
if (is_resource($countStream)) {
fclose($countStream);
}
$stat['size'] = $size;
$totalWritten = $writtenSize;
});
if ($this->objectStore instanceof IObjectStoreMetaData) {
$this->objectStore->writeObjectWithMetaData($urn, $countStream, $metadata);
} else {
if ($this->objectStore instanceof IObjectStoreMetaData) {
$this->objectStore->writeObjectWithMetaData($urn, $stream, $metadata);
} else {
$this->objectStore->writeObject($urn, $stream, $metadata['mimetype']);
}
if (is_resource($stream)) {
fclose($stream);
}
$this->objectStore->writeObject($urn, $countStream, $metadata['mimetype']);
}
if (is_resource($countStream)) {
fclose($countStream);
}
$stat['size'] = $totalWritten;
} catch (\Exception $ex) {
if (!$exists) {
/*
@@ -545,7 +543,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFil
]
);
}
throw $ex; // make this bubble up
throw new GenericFileException('Error while writing stream to object store', 0, $ex);
}
if ($exists) {
@@ -561,7 +559,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFil
}
}
return $size;
return $totalWritten;
}
public function getObjectStore(): IObjectStore {
@@ -6,6 +6,8 @@
*/
namespace OC\Files\ObjectStore;
use Aws\Command;
use Aws\Exception\MultipartUploadException;
use Aws\S3\Exception\S3MultipartUploadException;
use Aws\S3\MultipartCopy;
use Aws\S3\MultipartUploader;
@@ -96,7 +98,9 @@ trait S3ObjectTrait {
protected function writeSingle(string $urn, StreamInterface $stream, array $metaData): void {
$mimetype = $metaData['mimetype'] ?? null;
unset($metaData['mimetype']);
$this->getConnection()->putObject([
unset($metaData['size']);
$args = [
'Bucket' => $this->bucket,
'Key' => $urn,
'Body' => $stream,
@@ -104,7 +108,13 @@ trait S3ObjectTrait {
'ContentType' => $mimetype,
'Metadata' => $this->buildS3Metadata($metaData),
'StorageClass' => $this->storageClass,
] + $this->getSSECParameters());
] + $this->getSSECParameters();
if ($size = $stream->getSize()) {
$args['ContentLength'] = $size;
}
$this->getConnection()->putObject($args);
}
@@ -119,12 +129,15 @@ trait S3ObjectTrait {
protected function writeMultiPart(string $urn, StreamInterface $stream, array $metaData): void {
$mimetype = $metaData['mimetype'] ?? null;
unset($metaData['mimetype']);
unset($metaData['size']);
$attempts = 0;
$uploaded = false;
$concurrency = $this->concurrency;
$exception = null;
$state = null;
$size = $stream->getSize();
$totalWritten = 0;
// retry multipart upload once with concurrency at half on failure
while (!$uploaded && $attempts <= 1) {
@@ -139,6 +152,15 @@ trait S3ObjectTrait {
'Metadata' => $this->buildS3Metadata($metaData),
'StorageClass' => $this->storageClass,
] + $this->getSSECParameters(),
'before_upload' => function (Command $command) use (&$totalWritten) {
$totalWritten += $command['ContentLength'];
},
'before_complete' => function ($_command) use (&$totalWritten, $size, &$uploader, &$attempts) {
if ($size !== null && $totalWritten != $size) {
$e = new \Exception('Incomplete multi part upload, expected ' . $size . ' bytes, wrote ' . $totalWritten);
throw new MultipartUploadException($uploader->getState(), $e);
}
},
]);
try {
@@ -155,6 +177,9 @@ trait S3ObjectTrait {
if ($stream->isSeekable()) {
$stream->rewind();
}
} catch (MultipartUploadException $e) {
$exception = $e;
break;
}
}
@@ -180,7 +205,9 @@ trait S3ObjectTrait {
public function writeObjectWithMetaData(string $urn, $stream, array $metaData): void {
$canSeek = fseek($stream, 0, SEEK_CUR) === 0;
$psrStream = Utils::streamFor($stream);
$psrStream = Utils::streamFor($stream, [
'size' => $metaData['size'] ?? null,
]);
$size = $psrStream->getSize();