write method

Future<void> write(
  1. String path,
  2. Stream<List<int>> data, {
  3. bool create = false,
  4. int? offset,
  5. bool truncate = false,
  6. int? count,
  7. bool parents = false,
  8. int? cidVersion,
  9. bool? rawLeaves,
  10. String? hash,
  11. int? mode,
  12. int? mtimeSecs,
  13. int? mtimeNsecs,
})

Writes data to a file at the given path.

Mirrors Kubo ipfs files write semantics:

  • create (default false): create the file if it does not exist; without it, writing a missing path is an error.
  • truncate (default false): discard existing content and zero-fill up to offset before writing. With truncate false the existing tail beyond the written range is preserved.
  • offset starts writing at the given byte position; an offset beyond the current end of a non-truncated file is an error.
  • count limits how many bytes from data are written.
  • parents creates missing parent directories.
  • cidVersion, rawLeaves and hash control the CID format of the newly built UnixFS DAG.
  • mode, mtimeSecs and mtimeNsecs store optional UnixFS 1.5 metadata; supplying any of them disables raw leaves, matching Kubo.

When the existing file already stores an mtime and no explicit mtimeSecs is given, the mtime is bumped to the current time — matching Kubo's automatic mtime update on files write.

Full preservation of existing chunk boundaries is future work; at present unmodified bytes are read back and the DAG is rebuilt from the merged byte stream.

Implementation

Future<void> write(
  String path,
  Stream<List<int>> data, {
  bool create = false,
  int? offset,
  bool truncate = false,
  int? count,
  bool parents = false,
  int? cidVersion,
  bool? rawLeaves,
  String? hash,
  int? mode,
  int? mtimeSecs,
  int? mtimeNsecs,
}) async {
  final parts = _splitPath(path);
  if (parts.isEmpty) {
    throw Exception('Cannot write to MFS root path: $path');
  }
  final startOffset = offset ?? 0;

  if (startOffset < 0) {
    throw ArgumentError('Offset cannot be negative');
  }
  if (count != null && count < 0) {
    throw ArgumentError('Count cannot be negative');
  }

  await _mutationLock.synchronized(() async {
    final existingCid = await _resolvePath(_rootCid!, parts);
    final bool hasExisting = existingCid != null;

    if (!hasExisting && !create) {
      throw Exception('File does not exist and create is false: $path');
    }

    _NodeMetadata? existingMeta;
    if (hasExisting) {
      final existingType = await _unixfsType(existingCid);
      if (existingType == Data_DataType.Directory) {
        throw Exception('Cannot write over directory: $path');
      }
      existingMeta = await _readMetadata(existingCid);
    }

    final allBytes = await data.expand((b) => b).toList();
    final bytes = count == null
        ? Uint8List.fromList(allBytes)
        : Uint8List.fromList(allBytes.take(count).toList());

    Uint8List updatedBytes;
    if (truncate) {
      // Truncate semantics: zero-fill up to offset, then write data.
      final buffer = BytesBuilder()
        ..add(Uint8List(startOffset))
        ..add(bytes);
      updatedBytes = buffer.toBytes();
    } else {
      // Partial update: read existing file, patch the requested range, and
      // rebuild the DAG. A missing file (create=true) is treated as empty.
      // An offset beyond the end of file zero-fills the gap, matching
      // Kubo's DagModifier sparse expansion on seek.
      // Note: true chunk-boundary preservation is complex and
      // left as future work; we rebuild from the merged byte stream.
      final existingBytes = hasExisting
          ? await _readAllBytes(existingCid)
          : Uint8List(0);
      updatedBytes = Uint8List.fromList(
        _patchBytes(existingBytes, bytes, startOffset),
      );
    }

    // Determine effective UnixFS 1.5 metadata. Explicit parameters win;
    // otherwise carry over stored metadata (with an mtime bump), matching
    // Kubo's mtime-preserving write behavior. Storing metadata forces a
    // dag-pb root and disables raw leaves.
    final effectiveMode = mode ?? existingMeta?.mode;
    var effectiveMtimeSecs = mtimeSecs ?? existingMeta?.mtimeSecs;
    var effectiveMtimeNsecs = mtimeNsecs ?? existingMeta?.mtimeNsecs;
    if (mtimeSecs == null && existingMeta?.hasMtime == true) {
      final now = DateTime.now().toUtc();
      effectiveMtimeSecs = now.millisecondsSinceEpoch ~/ 1000;
      effectiveMtimeNsecs = (now.millisecondsSinceEpoch % 1000) * 1000000;
    }
    final hasMeta =
        effectiveMode != null ||
        effectiveMtimeSecs != null ||
        effectiveMtimeNsecs != null;

    final builder = UnixFSBuilder(
      cidVersion: cidVersion ?? 0,
      rawLeaves: hasMeta ? false : (rawLeaves ?? false),
      hashType: hash ?? 'sha2-256',
    );
    final blocks = await builder
        .build(Stream.fromIterable([updatedBytes]))
        .toList();
    if (blocks.isEmpty) {
      throw Exception('Failed to build UnixFS DAG');
    }
    var rootBlock = blocks.last;
    for (final block in blocks) {
      await _blockStore.putBlock(block);
    }
    if (hasMeta) {
      rootBlock = await _applyMetadata(
        rootBlock,
        mode: effectiveMode,
        mtimeSecs: effectiveMtimeSecs,
        mtimeNsecs: effectiveMtimeNsecs,
        hashType: hash ?? 'sha2-256',
        cidVersion: cidVersion ?? 0,
      );
      await _blockStore.putBlock(rootBlock);
    }
    await _modifyPath(
      parts,
      (currentCid) async {
        if (currentCid == null && !create) {
          // Unreachable under the mutation lock: a missing file without
          // `create` already threw above, and an existing file resolves to
          // a non-null CID here. Kept as a defensive re-check.
          // coverage:ignore-start
          throw Exception('File does not exist and create is false');
          // coverage:ignore-end
        }
        return rootBlock.cid;
      },
      recursive: parents,
      isDirectory: false,
    );
  });
}