#!/usr/bin/env ruby require 'fuserofs' require 'socket' require 'timeout' begin require 'zlib' rescue LoadError puts 'zlib unavailable - will not perform crc32 checks' end # # A full ruby NNTP yenc-binaries filesystem for fuse # # rnntpfs allows you to mount a newsgroup or news server, to list and read yEnc encoded files # Files posted with nonstandard subject lines are ignored # # Directory listing is long if uncached, enable verbose mode to see the progression # # Usage: # ruby [-v] [-d] rnntpfs.rb nntpurl mountpoint # # nntpurl examples: # # nntp://server.com/group.test # # nntp://server.com:port/ # will list available newsgroups # # on the mounted filesystem, use : # * echo 1 > [mountdir]/.rnntpfs-ctrl/umount to unmount # * echo 1 > [mountdir]/.rnntpfs-ctrl/flush to empty the directory listing / file size cache # # Author:: Yoann Guillot (at ofjj.net) # Copyright:: Copyright (c) 2006 Yoann Guillot # Licence:: Redistributes under the terms of the GPL version 2 # class NntpFS < FuseROFS def initialize(url) super() @control_dir = '.rnntpfs-ctrl/' raise 'invalid url' unless md = %r{nntp://(\w+:\w+@)?([\w.-]+)(:\w+)?/([\w.-]+)?}.match(url) loginpass, host, port, @groupname = md.captures puts 'loginpass not implemented' if loginpass port = port ? port[1..-1] : 'nntp' @nntpclient = NntpClient.new(host, port) @cache_timeout = 24*60*60 @xoversize = 5000 @release_min_file_count = 3 @group_min_article_count = 15000 file_accessor_int :xoversize, :release_min_file_count, :group_min_article_count file_accessor :groupname @groupfiles = {} @groupreleases = {} @filesizes = {} end def get_file_size(path) unless gn = @groupname gn = path[1..-1][/^[^\/]*/] end fn = path[/[^\/]*$/] @filesizes[gn][fn].inject(0) { |a, b| a + b.to_i } / 2 end def get_file_content(path, off) unless gn = @groupname gn = path[1..-1][/^[^\/]*/] end fn = path[/[^\/]*$/] fparts = @groupfiles[gn][fn] psizes = @filesizes[gn][fn] info "reading #{fn.inspect} at #{off}" offset, data = nil if defined? @guessnextpart and @guessnextgroup == gn and @guessnextfile == fn and @guessnextoffset == off partid = @guessnextpart return '' if not fparts[partid] offset, data = download_decode_article gn, fparts[partid], path elsif off == 0 partid = 0 offset, data = download_decode_article gn, fparts[partid], path else # try to guess the part id of the good article partid = 0 encsz = 0 while psizes[partid] and off >= encsz + psizes[partid] encsz += psizes[partid] partid += 1 end return '' if not psizes[partid] loop do offset, data = download_decode_article gn, fparts[partid], path if off >= offset + data.length # assume each article has the same amount of raw (guess is less optimal otherwise) debug "bad guess +" partid += [1, (off-offset)/data.length].max return '' if not fparts[partid] elsif off < offset debug "bad guess -" partid -= 1 return '' if not fparts[partid] else debug "found" break end end end debug "got #{data.length} bytes" partid += 1 if not data.empty? @guessnextgroup = gn @guessnextfile = fn @guessnextpart = partid @guessnextoffset = offset + data.length end data[off-offset..-1].to_s end # returns ydecoded offset and data def download_decode_article(group, id, path) data = '' partoff = nil partlen = nil decode = false @nntpclient.assert_group group @nntpclient.body(id) { |l| if decode if l =~ /^=yend/ if l =~ / size=(\d+)/ size = $1.to_i info "bad size: #{data.length} should be #{size}" if data.length != size end if Object.const_defined? :Zlib and (l =~ /\bpcrc32=([0-9a-f]+)/i or l =~ /\bcrc32=([0-9a-f])/i) crc = $1.hex realcrc = Zlib.crc32(data) info "bad crc: #{realcrc} should be #{crc}" if realcrc != crc end # XXX retry once on error ? break else data << Ydecoder.decode_line(l) end elsif l =~ /^=ybegin/ if l =~ / size=(\d+)/ newsz = $1.to_i debug "update size: was #{@size_cache[path]}, is #{newsz}" @size_cache[path] = newsz end if l !~ / part\b/ partoff = 0 partlen = $1.to_i if l =~ / size=(\d+)/ decode = true end elsif l =~ /^=ypart/ partoff = $1.to_i - 1 if l =~ / begin=(\d+)/ partlen = $1.to_i - partoff if l =~ / end=(\d+)/ decode = true end } return decode ? [partoff, data] : [-1, ''] end def directory_listing(path) if not @groupname and path == '/' list_groups else md = %r{^/([^/]*)/(?:([^/]*)/)?}.match path if not @groupname group = md[1] rname = md[2] else group = @groupname rname = md[1] if md end if rname @groupreleases[group][rname] else list_group_files(group) end end end def list_group_files(group) gf = @groupfiles[group] = {} fsz = @filesizes[group] = {} # @groupfiles[group] = hash # keys = file names # values = array of id of news articles for this file # @groupfile[group]['name'][3-1] = article whose subject = '"name" yEnc (3/xx)' # @filesizes = same thing, with article length raise 'group error' if not md = @nntpclient.group(group).match(/^211 \d+ (\d+) (\d+) /) min, max = md.captures.map { |i| i.to_i } startmin = min lastmin = min - 1 info "listing files in #{group}" while (min < max and min != lastmin) lastmin = min retrycount = 0 begin puts "%.2f%%" % ((min-startmin)*100.0/(max - startmin)) if not $stdout.closed? @nntpclient.xover("#{min}-#{min + @xoversize - 1}") { |e| id, subject, from, date, mid, ref, bytes = e.split "\t" min = id.to_i + 1 bytes = bytes.to_i next if subject[0, 4] == 'Re: ' and bytes < 10000 next if subject !~ /"(.*?)".*? yEnc .*?\((\d+)\/(\d+)\)[^(]*$/ filename, num, nummax = $1, $2.to_i, $3.to_i filename.tr! '/', '_' next if num == 0 gf[filename] ||= [] fsz[filename] ||= [] if gf[filename][num-1] gf[filename] = gf[filename][0..nummax-1] fsz[filename] = fsz[filename][0..nummax-1] debug "already seen #{filename.inspect} part #{num}" # XXX repost of a file with fewer parts => bug ? end gf[filename][num-1] = id.to_i fsz[filename][num-1] = bytes.to_i gf[filename][nummax-1] ||= nil # to detect incomplete files } rescue Interrupt # allow ctrl-c to interrupt the loop info 'Interrupted' break rescue info "exception #{$!.class} #{$!.message}" retry if (retrycount += 1) < 5 end end info "100%" debug "ignoring incomplete files: " + gf.map { |k, v| k if v.include? nil }.compact.sort.inspect gf.delete_if { |k, v| v.include? nil } releases_dirs(group, gf.keys) end # create virtual subdirectories for similar files def releases_dirs(group, names) gr = @groupreleases[group] = {} names.each { |n| (gr[$1] ||= []) << n if n =~ /^(.+?)\.(?:sfv|nfo|nzb|(?:part\d+\.)?rar|r\d\d|\d\d\d|(?:vol\d+\+\d+\.)?par2)$/i } (gr.keys & names).each { |k| gr[k] << k } # handle release name == file ('foo' + 'foo.par2') gr.delete_if { |k, v| v.length < @release_min_file_count } gr.keys.map { |k| k + '/' } + (names - gr.values.flatten) end # retrieves the newsgroup list, reject non-binaries and small groups def list_groups groups = [] @nntpclient.list { |e| name, max, min = e.split sz = max.to_i - min.to_i groups << name if sz >= @group_min_article_count and name =~ /binaries/ and not name.index '/' } groups.map { |n| n << '/' } end end # nntp client # the interface is +method_missing+ # if you pass a block to the call, it signifies that the call returns a 'body', # and each line of it is passed to the block # otherwise the response line is returned class NntpClient def initialize host, port = 'nntp' @host = host @port = port @curgroup = nil @sock = nil end def debug(*s) puts(*s) if $DEBUG end private :debug def reconnect @sock.close if @sock @sock = TCPSocket.new @host, @port debug "< #{@sock.gets}" mode 'reader' group @curgroup if @curgroup end def assert_group name group name if @curgroup != name end def method_missing(m, *a) if m == :group @curgroup = a.first end cmd = ([m] + a).join ' ' retried = false begin debug "> #{cmd}" @sock.puts cmd str = Timeout::timeout(20, RuntimeError) { @sock.gets }.chomp! or raise 'partial read' debug "< #{str}" raise str if str.to_i == 400 rescue raise if retried or m == :quit retried = true reconnect retry end puts "!sent #{cmd}\n!got #{str}" rescue nil if str[0] != ?2 # no yield if response != 2xx if block_given? and str[0] == ?2 begin sstr = nil loop do sstr = @sock.gets.chomp! or raise 'partial read' break if sstr == '.' sstr = sstr[1..-1] if sstr[0] == ?. and sstr[1] == ?. yield sstr end ensure # consume data even on 'break' Timeout::timeout(30) { sstr = @sock.gets.chomp while sstr != '.' } end end str end end class Ydecoder def self.decode_line l specialchar = false buf = '' l.each_byte { |b| if specialchar specialchar = false buf << ((b - 64 - 42) & 255) else case b when ?=: specialchar = true when ?\n, ?\r, ?\0: puts "special char #{b.chr.inspect} in stream..." if $VERBOSE else buf << ((b - 42) & 255) end end } puts 'line ends with specialchar..' if specialchar and $VERBOSE buf end end if $0 == __FILE__ begin NntpFS.new(ARGV.shift).mount_under(ARGV.shift) rescue puts $! puts "Usage : #$0 nntp://[login:pass@]host[:port]/[groupname] " end end